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
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.
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.
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 \
+
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
+
+
Tested: the two files below, with Docker Compose on a Mac, nginx 1.29 as the proxy, a self-signed certificate for localhost, and a real Gradle build over HTTPS.
+
Not tested: a certificate from a public authority or automatic renewal, a public server on port 443, other proxies or load balancers, Kubernetes ingress, and long-running use.
+
+
+
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
+
+
Check
Result
+
https://localhost:8443/healthz with the certificate trusted
ok
+
The same without trusting the certificate
curl refuses (exit 60)
+
The cache's own port 8080 from the host
not reachable
+
Plain http:// to the proxy's TLS port
400
+
Upload with no password, through the proxy
401
+
Upload with the password, through the proxy
201, and the value reads back
+
Uploads of about 1 MB and 2 MB with nginx's default settings
413 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
+
For my self-signed test certificate I made Java trust it:
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.
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.
-
+
Step
Stored afterwards
Evicted so far
Which keys could be read
Store k1, k2, k3
3,000 bytes, 3 entries
0
all three
Read k1
unchanged
0
k1 is now the most recently used
Store k4 (would go over 3,500)
3,000 bytes, 3 entries
1
k1, k3, k4. k2 is gone.
Store k5
3,000 bytes, 3 entries
2
k3, 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.
It is early. The paid tiers are not built. Everything you can download today is the free core.
-
Security is the difference
-
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
+
More than kept patched.
-
Signed releases. Images and the checksums file are signed with Sigstore, with no signing key stored anywhere. How to verify.
-
Build provenance. Every image and binary archive carries a signed statement naming the CI run that built it.
-
A bill of materials. Every binary archive ships with an SBOM.
-
Published vulnerability statements. A VEX file with each release says which known vulnerabilities do and do not affect it.
-
Reproducible. Each image is assembled a second time on a separate machine, and the digests must match or the release fails.
-
-
Supply chain
-
-
Pinned build steps. Every GitHub Action our releases use is pinned to an exact commit, and a check in CI enforces it on every change. The container base image is pinned by digest.
-
Hash-checked installs. Python tooling in our CI is installed with hash checking, so a swapped package is refused.
-
A 7-day wait. Our CI holds new dependency versions for 7 days before adopting them, and Dependabot has the same cooling-off period.
-
A daily supply-chain check. Once a day, everything our build and CI pull in (actions, tools, packages) is checked against public advisory databases and malicious-package reports. Any hit opens an issue.
-
Scanned before release. Two independent scanners with different databases check every release candidate, and any finding blocks the release unless a published VEX statement explains it. RELEASING.md.
-
Rescanned every day. Every published image is scanned again daily, so a new vulnerability in something we shipped raises an issue.
-
-
Small, quiet and patched
-
-
Tiny and quiet. A distroless image with one static program and no shell. It sends no telemetry and makes no outbound connections.
-
A FIPS build. A FIPS variant uses Go's validated FIPS 140-3 cryptographic module.
-
Patched for everyone. We aim to ship dependency fixes within 48 hours of disclosure. That is a target, not a promise. Security patches are never withheld from the free tier.
A self-hosted remote build cache. It is one static binary or one container image, MIT licensed. It speaks Gradle's own HttpBuildCache protocol, and also the remote mode of Apache's Maven Build Cache Extension.
What makes FosterStack Cache's security different?
-
Every release is signed and carries build provenance, an SBOM and published vulnerability statements. Two independent scanners check each release candidate before it ships, and every published image is scanned again daily. Images are minimal, with one static program and no shell, and the server sends no telemetry. See the security policy and how we build.
+
Every release is signed and carries build provenance, an SBOM and published vulnerability statements. Two different scanners, with different vulnerability databases, check each release candidate before it ships, and every published image is scanned again daily. Images are minimal, with one static program and no shell, and the server sends no telemetry. See the security policy and how we build.
Does it work with Gradle?
Yes. Gradle talks to it with its normal remote build cache setting, so moving from the old node is mostly a URL change. Our CI builds a real multi-module Gradle project against it on every change and checks that a second build gets real cache hits. See the setup page.
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 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
+
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.
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
+
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.
+
+
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 at
First build
Second 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 there
not 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.
+
+
+
+
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..d67c443
--- /dev/null
+++ b/build-cache-permission-denied-data-directory/index.html
@@ -0,0 +1,137 @@
+
+
+
+
+
+Build cache server exits: permission denied on its data
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
fosterstack
+
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 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"}
+
+
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:
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
+
+
A new, empty named volume. This is what our first-run steps create, and the volume then belongs to 65532. I also tried a new empty volume whose top folder I had set to root. The server still started, and afterwards the folder belonged to 65532, because Docker copies the image's ownership into an empty volume the first time it is used.
+
+
+
What I did not test
+
+
A bind mount of a host folder on Linux. The same rule should apply (the folder has to be writable by 65532), but I ran on a Mac, where bind mounts behave differently.
+
Kubernetes without the fsGroup setting. The manifests in our Kubernetes page set fsGroup: 65532, and I ran them with it; I did not run them without it.
+
Running the container as root to avoid the problem. It is not what we document.
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
-
+
vCPU
RAM
Disk
Absolute minimum
1
1 GB
cache cap + about 5 GB for the system
Recommended start, about 5 to 20 developers plus CI
1 to 2
2 GB
25 to 50 GB SSD
Larger CI farms
2 to 4
4 to 8 GB
cache 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:
-
+
Cap
FSCACHE_MAX_BYTES
10 GiB
10737418240
20 GiB
21474836480
50 GiB
53687091200
100 GiB
107374182400
-
+
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
new file mode 100644
index 0000000..1953127
--- /dev/null
+++ b/build-cache-survives-restart/index.html
@@ -0,0 +1,141 @@
+
+
+
+
+
+Does the Build Cache Survive a Restart? A Real Test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
fosterstack
+
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.
+
+
Step
Server state before the build
Entries in the status page
From cache
+
+
1. First build
New, empty data folder A
0, then 13 afterwards
0 of 4
+
2. Clean restart
Stopped normally, started on folder A
13 (same as before)
4 of 4
+
3. Crash
Killed with kill -9, started on folder A
13 (same as before)
4 of 4
+
4. Wrong folder
Started on a different empty folder B
0
0 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:
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:
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
-
+
Step
Took
Download the release and verify its signature and checksum
1 second
Unpack it and start the server, with a password and a size cap
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.
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:
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 builds
Result
Server 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.
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
+
+
Tested: a small proxy I wrote for the test (it forwards plain HTTP, tunnels HTTPS with CONNECT, can refuse a host, and logs every request), the cache on a non-loopback address, once over plain HTTP and once over HTTPS through nginx with a self-signed certificate that Java was told to trust.
+
Not tested: a real corporate proxy product, a proxy that asks for a password (407), a proxy that inspects and re-signs TLS, automatic proxy configuration files, and the HTTP_PROXY variables. Maven is covered on its own page, where it behaves differently: for example it does not skip the proxy for loopback addresses.
+
+
+
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:
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.
+
+
Setup
Result
What the proxy logged
+
No proxy settings, cache at <cache-ip>
second build FROM-CACHE
nothing (not used)
+
Proxy settings, same plain-HTTP URL
FROM-CACHE
GET http://<cache-ip>:18130/<key>, one per build
+
Proxy settings, but the cache URL is 127.0.0.1
FROM-CACHE
nothing: Java skips the proxy for loopback addresses
+
Proxy settings, with the cache's address in nonProxyHosts
FROM-CACHE
nothing
+
Proxy settings, HTTPS cache, tunnel allowed
FROM-CACHE
CONNECT <cache-ip>:18443, one per build
+
Proxy settings, proxy refuses the cache host
the 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
+
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
+
+
Ask for the cache's host and port to be allowed through the proxy (HTTPS).
+
Put the proxy lines in gradle.properties, with the systemProp. prefix, and list internal hosts in nonProxyHosts.
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
new file mode 100644
index 0000000..537e939
--- /dev/null
+++ b/gradle-build-cache-enable-disable/index.html
@@ -0,0 +1,156 @@
+
+
+
+
+
+Enable or Disable the Gradle Build Cache: Flag or Property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
fosterstack
+
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.
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.
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.
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:
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.
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
-
+
Status
When you get it
What to do
200
A stored entry was found and sent.
Nothing. This is a cache hit.
201
An upload was stored.
Nothing.
@@ -98,11 +99,11 @@
The short table
401
No password, or the wrong one.
Fix the credentials block. See below.
403
A write sent with the read-only login.
Use the read-write login for machines that upload, or set the client not to upload.
413
An 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:
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.
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
+
+
Run
core
util
api
app
+
+
1. First build, as CI would do it
ran
ran
ran
ran
+
2. Another machine, same code
from cache
from cache
from cache
from cache
+
3. Edit inside a method in core
ran
from cache
from cache
from cache
+
4. A teammate makes the same edit
from cache
from cache
from cache
from cache
+
5. Add a public method to core
ran
ran
ran
ran
+
6. Edit only app
from cache
from cache
from cache
ran
+
+
+
"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:
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.
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:
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.
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 cache
Build cache
What it saves
The work of reading your build files and planning the tasks
The work of running a task, by reusing its saved output
Where it lives
A folder in your project, on your machine
On 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 when
A build file it depends on changes
A 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:
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:
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.
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.
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.
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:
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:
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.
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.
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 3 in its Build track.
+
Guidance we designed against, not a certification we hold.
+
+
The two NIST documents
+
+
NIST SP 800-204C, Implementation of DevSecOps for a Microservices-based Application with Service Mesh. We use its pipeline sections, mainly 4.6 "Securing the CI/CD Pipeline" and 4.8 "Security Testing".
+
NIST SP 800-204D, Strategies for the Integration of Software Supply Chain Security in DevSecOps CI/CD Pipelines. We use sections 4 and 5, mainly 5.1.1 "Secure Build" and 5.2 "Securing Workflows in CD Pipelines".
+
+
Parts of both documents are about running services in your own cluster: service mesh, runtime monitoring, deployment by GitOps. That part happens in your environment, not in our release pipeline, so this page does not map it.
+
+
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.
+
+
+
Stage
What it does in v0.2.1
NIST practice
How much
+
+
+
+
Source admission
+
Checks 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.
+
+
+
Build
+
Builds 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 assembly
+
Checks 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 check
+
Assembles 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.
+
+
+
Scans
+
Two 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 tests
+
Run 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 authorization
+
Verifies 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 publish
+
Will 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 stages
+
Keyless 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 bundle
+
release-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:
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.
FosterStack is a self-hosted remote build cache server — a Develocity Build Cache Node
replacement that speaks the same Gradle remote build cache HTTP protocol, so migrating is
@@ -106,7 +107,7 @@
Your build cache shouldn't die with its vendor.
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
What you get that a bare HTTP endpoint doesn't give you
Cache management
Size-capped LRU eviction that keeps a busy CI cache healthy without hand-tending — set the cap, and the oldest-unused entries make room.
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
Dependency CVEs remediated fast — target within 48 hours of disclosure. A stated intention until the paid tiers exist, and the release history is the track record either way.
-
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.
+
Maintenance target
Vulnerable dependencies fixed fast — our target is 48 hours from disclosure.
+
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.
-
Security is the difference
-
Security is what sets this product apart. 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
+
More than kept patched.
-
Signed releases. Images and the checksums file are signed with Sigstore, with no signing key stored anywhere. How to verify.
-
Build provenance. Every image and binary archive carries a signed statement naming the CI run that built it.
-
A bill of materials. Every binary archive ships with an SBOM.
-
Published vulnerability statements. A VEX file with each release says which known vulnerabilities do and do not affect it.
-
Reproducible. Each image is assembled a second time on a separate machine, and the digests must match or the release fails.
-
-
Supply chain
-
-
Pinned build steps. Every GitHub Action our releases use is pinned to an exact commit, and a check in CI enforces it on every change. The container base image is pinned by digest.
-
Hash-checked installs. Python tooling in our CI is installed with hash checking, so a swapped package is refused.
-
A 7-day wait. Our CI holds new dependency versions for 7 days before adopting them, and Dependabot has the same cooling-off period.
-
A daily supply-chain check. Once a day, everything our build and CI pull in (actions, tools, packages) is checked against public advisory databases and malicious-package reports. Any hit opens an issue.
-
Scanned before release. Two independent scanners with different databases check every release candidate, and any finding blocks the release unless a published VEX statement explains it. RELEASING.md.
-
Rescanned every day. Every published image is scanned again daily, so a new vulnerability in something we shipped raises an issue.
-
-
Small, quiet and patched
-
-
Tiny and quiet. A distroless image with one static program and no shell. It sends no telemetry and makes no outbound connections.
-
A FIPS build. A FIPS variant uses Go's validated FIPS 140-3 cryptographic module.
-
Patched for everyone. We aim to ship dependency fixes within 48 hours of disclosure. That is a target, not a promise. Security patches are never withheld from the free tier.
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:
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
+
+
Tested: a small proxy I wrote for the test, which forwards plain HTTP, can refuse a host, and logs every request (I made it close each connection after one request so that none go unlogged). The cache was on a non-loopback address, over plain HTTP, with a password.
+
Not tested: HTTPS through a proxy for Maven, a proxy that asks for a password, other Maven or extension versions, and any real proxy product. Gradle is on its own page.
+
+
+
The settings
+
In ~/.m2/settings.xml, next to the cache's password entry:
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):
The second build logged Found cached build, restoring demo:demo from cache.
+
+
Other cases
+
+
Setup
What happened
+
Proxy configured, cache URL is 127.0.0.1
Still 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 host
Nothing was stored or restored. The build succeeded without the cache, and logged Error downloading cache item with HTTP Status: 403.
+
Proxy not running at all
The 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
+
+
A local test can fail because of the proxy. If you try the cache on 127.0.0.1 with a proxy configured in settings.xml, Maven sends it to the proxy, which may not be able to reach your machine's loopback. List it in <nonProxyHosts>.
+
A refusing proxy looks like a cache error. The 403 comes from the proxy, not from the cache. To check, run the same request from the build machine with and without the proxy; see the curl check on the Gradle page.
+
Failure does not break the build. A build behind a blocking proxy just stops using the cache, so look for Error downloading cache item and for missing Saved to remote cache lines.
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.
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.
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.
+
+
Build
core
util
app
+
+
1. First build, as CI would do it
built
built
built
+
2. Another machine, same code
restored
restored
restored
+
3. Edit inside a method in core
built
built
built
+
4. A teammate makes the same edit
restored
restored
restored
+
5. Edit only app
restored
restored
built
+
6. Edit only util
restored
built
built
+
+
+
"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:
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.
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
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 remote element goes insideconfiguration. 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.
[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:
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:
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:
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
Once builds hit the new server, stop the old Build Cache Node container.
-
Security is the difference
-
Security is what sets this product apart. 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
+
More than kept patched.
-
Signed releases. Images and the checksums file are signed with Sigstore, with no signing key stored anywhere. How to verify.
-
Build provenance. Every image and binary archive carries a signed statement naming the CI run that built it.
-
A bill of materials. Every binary archive ships with an SBOM.
-
Published vulnerability statements. A VEX file with each release says which known vulnerabilities do and do not affect it.
-
Reproducible. Each image is assembled a second time on a separate machine, and the digests must match or the release fails.
-
-
Supply chain
-
-
Pinned build steps. Every GitHub Action our releases use is pinned to an exact commit, and a check in CI enforces it on every change. The container base image is pinned by digest.
-
Hash-checked installs. Python tooling in our CI is installed with hash checking, so a swapped package is refused.
-
A 7-day wait. Our CI holds new dependency versions for 7 days before adopting them, and Dependabot has the same cooling-off period.
-
A daily supply-chain check. Once a day, everything our build and CI pull in (actions, tools, packages) is checked against public advisory databases and malicious-package reports. Any hit opens an issue.
-
Scanned before release. Two independent scanners with different databases check every release candidate, and any finding blocks the release unless a published VEX statement explains it. RELEASING.md.
-
Rescanned every day. Every published image is scanned again daily, so a new vulnerability in something we shipped raises an issue.
-
-
Small, quiet and patched
-
-
Tiny and quiet. A distroless image with one static program and no shell. It sends no telemetry and makes no outbound connections.
-
A FIPS build. A FIPS variant uses Go's validated FIPS 140-3 cryptographic module.
-
Patched for everyone. We aim to ship dependency fixes within 48 hours of disclosure. That is a target, not a promise. Security patches are never withheld from the free tier.
The old node had a small web page with a purge button. FosterStack Cache has no settings page. You set it with flags and environment variables, and a read-only status page shows size and hit counts. To empty the cache, remove its volume and start again.
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..9a0972c
--- /dev/null
+++ b/migrate-maven-build-cache-to-a-new-server/index.html
@@ -0,0 +1,154 @@
+
+
+
+
+
+Move a Maven Build Cache to a New Server: Real Run
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
fosterstack
+
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.
+
+
Build
Server it used
Result
+
+
1
Old server, empty
Built the project and saved the result to the server
+
2
Old server, from another machine
Restored from the cache, no compile
+
3
New server, empty, only the address changed
Full build, saved to the new server
+
4
New server, from another machine
Restored from the cache, no compile
+
5
New server started on a copy of the old data folder
Restored 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:
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.
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.
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.
These are the names a running 0.2.1 server reports:
-
+
Metric
Type
What it tells you
fscache_cache_hits_total
counter
Requests answered from the cache
fscache_cache_misses_total
counter
Requests for something not stored
@@ -112,10 +113,10 @@
The metrics
fscache_http_request_duration_seconds
histogram
How long requests take
fscache_bytes_read_total
counter
Bytes sent to clients
fscache_bytes_written_total
counter
Bytes 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:
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.
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.)
-
+
Build
Result
Server afterwards
Gradle, first
:compileJava ran
1 entry
Gradle, second
> Task :compileJava FROM-CACHE, "1 from cache"
1 hit
Maven, first
built, then three lines of Saved to remote cache
4 entries in all
Maven, second
Found cached build, restoring demo:maven-app from cache
3 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:
federal or compliance requirements attached — FIPS-validated cryptography,
SBOMs, provenance, a security questionnaire with two hundred rows — and
suddenly every tool in your build pipeline needs paperwork it has never
- had. Big vendors sell their way through that pile with compliance teams
- and six-figure contracts. Small teams mostly just lose the deal.
+ had. Big vendors have compliance teams to get through that pile. Small teams
+ mostly just lose the deal.
The cost of compliance shouldn't determine who gets to
@@ -116,32 +116,16 @@
What our piece does — and does not do
signed, and checkable instead of a shrug.
-
Security is the difference
-
Security is what sets this product apart. 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
+
More than kept patched.
-
Signed releases. Images and the checksums file are signed with Sigstore, with no signing key stored anywhere. How to verify.
-
Build provenance. Every image and binary archive carries a signed statement naming the CI run that built it.
-
A bill of materials. Every binary archive ships with an SBOM.
-
Published vulnerability statements. A VEX file with each release says which known vulnerabilities do and do not affect it.
-
Reproducible. Each image is assembled a second time on a separate machine, and the digests must match or the release fails.
-
-
Supply chain
-
-
Pinned build steps. Every GitHub Action our releases use is pinned to an exact commit, and a check in CI enforces it on every change. The container base image is pinned by digest.
-
Hash-checked installs. Python tooling in our CI is installed with hash checking, so a swapped package is refused.
-
A 7-day wait. Our CI holds new dependency versions for 7 days before adopting them, and Dependabot has the same cooling-off period.
-
A daily supply-chain check. Once a day, everything our build and CI pull in (actions, tools, packages) is checked against public advisory databases and malicious-package reports. Any hit opens an issue.
-
Scanned before release. Two independent scanners with different databases check every release candidate, and any finding blocks the release unless a published VEX statement explains it. RELEASING.md.
-
Rescanned every day. Every published image is scanned again daily, so a new vulnerability in something we shipped raises an issue.
-
-
Small, quiet and patched
-
-
Tiny and quiet. A distroless image with one static program and no shell. It sends no telemetry and makes no outbound connections.
-
A FIPS build. A FIPS variant uses Go's validated FIPS 140-3 cryptographic module.
-
Patched for everyone. We aim to ship dependency fixes within 48 hours of disclosure. That is a target, not a promise. Security patches are never withheld from the free tier.
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:
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:
Send the report, not a patch. Please do not post details publicly before a fix ships.
What we aim for
-
We aim to ship fixes for dependency vulnerabilities within 48 hours of public disclosure. That is a target, not a promise you can hold us to. Security patches are never withheld from the free tier: the same fix ships to everyone on the same day.
+
Vulnerable dependencies fixed fast — our target is 48 hours from disclosure. Security patches are never withheld from the free tier: the same fix ships to everyone on the same day.
The supported version is the latest minor release.
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
+
+
Tested: Docker, one named volume at /home/nonroot, release 0.1.0 and then 0.2.1 (and back), no password, stopped and started cleanly between versions.
+
Not tested: any other pair of releases (there is no 0.2.0 release; see the changelog), upgrading while the server is running, Kubernetes or Compose rollouts, the FIPS image, or a cache that was close to its size cap.
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
+
+
Step
Result
+
0.1.0: store three entries on a fresh volume
201 each; its metrics show 3 entries, 29 bytes
+
Stop 0.1.0, start 0.2.1 on the same volume
starts normally; the status page shows "version": "v0.2.1", 3 entries, 29 bytes; all three values read back
+
0.2.1: store two more
201 each; 5 entries
+
Stop 0.2.1, start 0.1.0 on the same volume again
starts 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
+
+
0.1.0 has no status page./statusz returned 404 there. 0.2.1 has it. The metrics page is on both.
+
0.1.0's entry count resets after a restart. After the rollback its metric fscache_store_entries read 0 although every value was readable. I checked whether that was caused by the upgrade: it is not. A volume only 0.1.0 ever wrote showed 3 entries before a plain restart and 0 after it, with the data still readable. 0.2.1, restarted the same way, reported 3 entries and 9 bytes correctly.
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.
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.
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.
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:
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.
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".
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.
Look for signatures, a software bill of materials, build provenance and vulnerability statements, and check whether you need an account to see them. FosterStack Cache: signed images and checksums, an SBOM per binary archive, build provenance and a VEX file, all public. See how we build.
4. Is it kept patched?
-
Ask how often published files are rescanned and how fast fixes ship. FosterStack Cache: a release is blocked on scanner findings unless a VEX statement explains them, and published images are rescanned daily. We aim to ship dependency fixes within 48 hours of disclosure. That is a target, not a promise.
+
Ask how often published files are rescanned and how fast fixes ship. FosterStack Cache: a release is blocked on scanner findings unless a VEX statement explains them, and published images are rescanned daily. Vulnerable dependencies fixed fast — our target is 48 hours from disclosure.
5. Does it call home?
A server in your build network should not need to reach out. FosterStack Cache: no telemetry, no update check, no licence check. It can run on a network with no route to the internet.