A small, self-contained GitHub Actions cache server: one static binary that
serves both the legacy artifactcache v1 REST API (what Forgejo act_runner
speaks) and the v2 Twirp CacheService API (what the GitHub Actions toolkit
and docker buildx --cache-to type=gha speak) over just two ports: one serves
the runner-facing endpoints, the other serves the clustering needs (see
Clustering).
- Blobs live on the local filesystem under a directory you choose; metadata and usage accounting live in a SQLite file beside them.
- Auth is a shared secret: Forgejo HMAC headers, bearer tokens, or both.
- Optional peer-to-peer clustering discovers nodes, tracks capacity, and places a cache write on the least-loaded healthy owner.
Dogfooded. cachette serves cachette: this project's own CI uses a live deployment as the
docker buildxlayer cache for every build (see CI & releases).
- Dual protocol on the cache port. v1
/_apis/artifactcache/*and v2/twirp/github.actions.results.api.v1.CacheService/*(plus the v2 Azure block-blob upload emulation) are served by the same binary. - Chunked, resumable uploads. v1
PATCHchunks are staged and committed; abandoned reservations are reaped on a timer. - Capacity model with eviction.
lru,oldest, ornone, driven by amin_freefree-space floor that accepts a fraction, a percent, or a byte size. - Signed capability URLs. Downloads are authorized by a short-lived
?id&exp&macHMAC token; there is no anonymous read path. - Cluster placement. Seed peers by address and pin their certificate fingerprints; the entry node streams a v1 upload to the least-loaded healthy owner over pinned mTLS (the blob bytes never touch the entry node).
- Self-signed node identity. First boot mints an Ed25519 keypair and certificate; the fingerprint is stable across restarts and storage wipes.
-
Build (or download) the
cachettebinary. The only runtime requirement is the binary itself; the filesystem storage backend and SQLite index are built in. -
Write a config file. At minimum, set a storage path and an auth secret of at least 16 characters:
[server] listen = "0.0.0.0:8080" # Set this to your public URL when a proxy terminates TLS for you, so the # archive/capability URLs handed to clients are absolute and correct: external_url = "https://cache.example.com" [storage.fs] path = "/var/lib/cachette" [auth] mode = "merge" secret = "change-me-to-16-plus-chars"
-
Start it:
./cachette --config /etc/cachette/cachette.toml
On the first boot the server mints a node identity under the storage path (or
cluster.cert_path), then prints a readiness line:cachette listening on http://0.0.0.0:8080With
[cluster] enabled, the boot log adds a second readiness line for the peer port -cachette cluster listening on https://0.0.0.0:8081- which peers dial; runners never see it. -
Check liveness. The main listener is plain HTTP unless a
[server] tls_cert/tls_keypair is configured:curl http://127.0.0.1:8080/health # {"version":"1.0.0","node_id":"<64-hex fingerprint>"}GET /metricson the same listener returns the Prometheus text-format counters (requests_total{route,code},bytes_in,bytes_out,evictions_total,reservations_active,storage_used_bytes):curl http://127.0.0.1:8080/metrics
If TLS is enabled on the listener, probe it with
curl -k https://...(supply a CA-trusted certificate throughserver.tls_cert/server.tls_keyfor real clients); see docs/tls.md. -
Point your runner at it. See docs/forgejo.md for Forgejo
act_runner, docs/clients.md for the GitHub toolkit anddocker buildx --cache-to type=gha, and compose.yaml for a container sample.
Configuration is a single TOML file. Every key is denied if unknown: a typo or a key from a newer release is a hard startup error, not a silent default.
When --config is not passed, cachette looks for cachette.toml in a
platform-specific directory. An explicit --config <path> always overrides the
default (and a missing explicit path is an error rather than a silent
fallback).
| Platform | Directory | File |
|---|---|---|
| Linux / BSD / macOS | /etc/cachette |
/etc/cachette/cachette.toml |
| Windows | %APPDATA%\cachette |
%APPDATA%\cachette\cachette.toml |
| Docker image | /config |
/config/cachette.toml |
If no default file is present, cachette starts on the built-in defaults (and
still refuses to serve until auth.secret is set). A ready-to-edit file with
every key and its default is cachette.example.toml.
Compact view; docs/configuration.md has the full reference, valid-value notes, and examples.
| Section | Key | Value | Default |
|---|---|---|---|
server |
listen |
host:port (IPv6 as [::1]:8080) |
0.0.0.0:8080 |
server |
external_url |
absolute URL advertised to clients | derived from listen as http://<host>:<port> (wildcard host becomes loopback) |
server |
tls_cert |
PEM certificate path (with tls_key) |
unset (listener is plain HTTP) |
server |
tls_key |
PEM private-key path (with tls_cert) |
unset |
storage |
backend |
fs |
fs |
storage.fs |
path |
blob root directory | /var/lib/cachette |
storage.fs |
min_free |
fraction (0.2), percent ("20%"), or bytesize ("2GiB"/"2GB", 1024-based) |
0.2 |
cache |
max_size |
bytes, 0 = unlimited |
0 |
cache |
eviction |
lru, oldest, none |
lru |
cache |
key_case |
lower, preserve |
preserve |
cache |
reservation_ttl_seconds |
seconds, must be > 0 |
3600 |
auth |
mode |
hmac, bearer, merge |
merge |
auth |
secret |
16+ characters, required to serve | unset |
auth |
tokens |
list of static bearer tokens | [] |
auth |
hmac_max_age_seconds |
seconds, 0 = disabled |
600 |
v1 |
signed_url_ttl_seconds |
seconds (governs v1 and v2 capability URLs) | 900 |
v2 |
jwt_secret |
HS256 secret (falls back to auth.secret) |
unset |
v2 |
jwt_ttl_seconds |
seconds (governs the Twirp JWT) | 300 |
cluster |
enabled |
boolean | false |
cluster |
node_id |
string label | unset (uses the identity fingerprint) |
cluster |
listen |
host:port dedicated mTLS peer listener |
0.0.0.0:8081 |
cluster |
advertise_addr |
host:port peers dial |
cluster.listen |
cluster |
mdns |
boolean | true |
cluster |
seeds |
list of host[:port] |
[] |
cluster |
seed_fingerprints |
map of node_id to hex fingerprint |
{} |
cluster |
tiebreak |
random, roundrobin, progressive |
random |
cluster |
cert_path |
identity directory | <storage.fs.path>/identity |
cluster |
heartbeat_interval_seconds |
seconds | 5 |
cluster |
peer_timeout_seconds |
seconds | 15 |
log |
level |
trace, debug, info, warn, error |
info |
log |
format |
text, json |
text |
Every key can be set or overridden by an environment variable; environment
values win over the file. The convention is CACHETTE__SECTION__KEY
(double underscore between segments), and names are case-insensitive:
export CACHETTE__SERVER__LISTEN="0.0.0.0:8080"
export CACHETTE__AUTH__MODE="merge"
export CACHETTE__AUTH__SECRET="change-me-to-16-plus-chars"
./cachette --print-config- Scalars are written as text (
CACHETTE__CACHE__MAX_SIZE="1024"). Booleans aretrue/false; unsigned integers must parse as an unsigned integer. - Lists are comma-separated, with surrounding spaces trimmed and empty
items dropped:
CACHETTE__AUTH__TOKENS="token-one, token-two"andCACHETTE__CLUSTER__SEEDS="cache-a:8080,cache-b:8080". - Maps are a JSON object of string values:
CACHETTE__CLUSTER__SEED_FINGERPRINTS='{"cache-a":"AA11","cache-b":"BB22"}'. - Bare keys without a section prefix-match a unique field name, e.g.
CACHETTE__LISTENandCACHETTE__ENABLED. - A
CACHETTE__*variable that maps to no known key is a hard error that names the offending variable; non-CACHETTEvariables are ignored.
cachette serve re-reads the config file on SIGHUP (Unix) without dropping
connections. Reloadable: [auth] (mode, secret, tokens,
hmac_max_age_seconds), the per-request [cache]/[storage.fs] admission
numbers (max_size, min_free, reservation_ttl_seconds, key_case,
signed_url_ttl_seconds, max_chunk_bytes), and [log] level. Boot-only
(listener, storage backend and data directory, cluster wiring, [v2] JWT
secret) — restart to change. A malformed file is rejected and the previous
config stays in effect. See
docs/configuration.md.
cachette [OPTIONS] [COMMAND]
Commands:
identity Print the node fingerprint and identity certificate path
identity rotate Renew the certificate for the same key (fingerprint unchanged)
verify Recompute every published entry's digest and report mismatches
cache list List finalized cache entries (--json for machine output)
cache prune Delete entries not accessed within --older-than <dur>
gc Run the reservation reaper + orphan-blob sweep once, then exit
doctor Check config, storage, SQLite, identity, seeds, and disk
token mint
hmac Print a Forgejo artifactcache HMAC token for the secret
buildx-jwt Print a JWT to use with buildx cache exports
cluster status Print the live cluster member table from a running server
Options:
--config <path> Path to a TOML config file (overrides the platform default)
--print-config Print the resolved configuration (secrets masked) and exit
-h, --help Print help
-V, --version Print version
cachette --print-configresolves the file plus environment, then prints the effective configuration as JSON with every secret field masked as***. It succeeds even whenauth.secretis unset.cachette identityprints the node fingerprint and the identity certificate path, minting the identity on first run. Use it to collectcluster.seed_fingerprintsvalues.cachette identity rotatere-issues the identity certificate for the same key with a refreshed validity window, then prints the (unchanged) fingerprint and cert path. Because a pin is the key's SPKI fingerprint — not the certificate, whose validity window peers ignore — every existingcluster.seed_fingerprintsentry stays valid: no re-seed, no cluster coordination. It exits non-zero when the identity is missing; it never mints a new key, which would change the fingerprint.cachette verifyre-hashes every published blob against the digest recorded at publish time (plan task 50) and exits non-zero, naming each corrupt / missing / size-mismatched entry.cachette cache list [--json]andcachette cache prune --older-than 7dinspect and selectively evict entries through the same storage path the server uses.--dry-runreports without deleting.cachette gcruns one TTL-reaper + orphan-blob sweep (the same pass the server runs periodically) and exits.cachette doctoris a read-only health check: config, writable data dir, SQLitePRAGMA integrity_check, identity fingerprint, seed reachability, and disk headroom. It exits non-zero when any check fails.cachette token mint --scope read|write [--repo owner/repo] [--run N]prints theForgejo-Cache-*header values for the configuredauth.secret, so an operator never hand-rolls the HMAC.cachette cluster status [--watch]reads the live member table from a running server over a loopback admin endpoint. It is the only subcommand that needs a live server.verify,cache, andgctake an exclusive lock on the data directory and refuse with a clear message while a server (or another ops command) holds it.doctorandtoken mintnever touch the data directory and do not lock.cachette --versionprintscachette 1.0.0.- A configuration error exits with status
2.
Requests never fall through to anonymous access: a missing or invalid
credential is 401 {"error":"authentication required"}.
auth.mode |
v1 /_apis/artifactcache/* |
v2 Twirp CacheService |
Capability URLs (/v2/upload, /v2/artifacts) |
|---|---|---|---|
hmac |
Forgejo-Cache-* HMAC headers only |
static/dynamic bearer tokens; self-minted JWT rejected | signed ?id&exp&mac |
bearer |
Authorization: Bearer <token> |
static/dynamic bearer token, or a self-minted HS256 JWT | signed ?id&exp&mac |
merge |
HMAC headers or bearer token | static/dynamic bearer token, or a self-minted HS256 JWT | signed ?id&exp&mac |
Notes:
- A static token is any string in
[auth] tokens; a dynamic token is registered at runtime throughPOST /_internal/register?token=<token>and revoked withPOST /_internal/revoke?token=<token>. Those admin routes acceptAuthorization: Bearer <auth.secret>, or are loopback-only while cluster mode is disabled. - The v2 capability URLs (
/v2/upload/{id},/v2/artifacts/{id}) are authorized only by their signed URL, never by a bearer token. - Artifact downloads on v1 are likewise authorized by the signed
archiveLocation; both real clients fetch it with noAuthorizationheader.
A signed URL carries ?id=<id>&exp=<unix>&mac=<hex>, where
mac = HMAC-SHA256(auth.secret, "{path}|{id}|{exp}"). The resource path is
bound into the MAC, so any change to the path, id, or expiry fails with
403 {"error":"invalid or expired url"}.
- Two TTL knobs govern validity:
v1.signed_url_ttl_seconds(default900) covers both the v1archiveLocationand the v2 capability URLs;v2.jwt_ttl_seconds(default300) covers the Twirp JWT. - Expiry is checked when the request starts; a transfer already in progress runs to completion.
- To revoke outstanding URLs and JWTs, rotate
auth.secret(andv2.jwt_secretif you set one separately). Static bearer tokens in[auth] tokensare unaffected unless you also change the list.
Keep the TTL short and rotate the secret if a URL leaks.
The main listener is plaintext HTTP unless a [server] tls_cert/tls_key
pair is configured: no implicit TLS to fight with, and a reverse proxy that
terminates the public TLS can forward to plain HTTP. When the pair is set, the
listener serves that certificate and clients need it CA-trusted (or a trust
override). Peers have their own dedicated listener — [cluster] listen
(default 0.0.0.0:8081, bound only when [cluster] enabled), which always
terminates TLS with the self-signed node identity certificate; peer trust is a
fingerprint pin, not a CA chain. Recipes for nginx, Traefik, ssh -L/-R,
WireGuard, and proxy-level rate limiting are in
docs/tls.md.
Set [cluster] enabled = true, list peers in seeds, and pin their
fingerprints in seed_fingerprints. Nodes discover each other, heartbeat their
capacity, and place each v1 cache write on the least-loaded healthy owner, which
streams the blob straight to that owner over pinned mTLS. Peers connect on the
dedicated [cluster] listen port (default 0.0.0.0:8081), which always serves
the self-signed identity certificate — it is never the runner-facing port.
cluster.mdns advertises on the local link by default (disable it on a VPS with
no usable multicast interface). See docs/cluster.md.
- docs/configuration.md - full configuration reference, environment overrides, validation, and logging.
- docs/forgejo.md - wiring a Forgejo
act_runnercache proxy (v1 + HMAC). - docs/clients.md - the GitHub Actions toolkit,
docker buildx --cache-to type=gha, and GitHub/Gitea bearer auth. - docs/cluster.md - enable clustering, seeds, mDNS, pinning and trust-on-first-use, and placement.
- docs/tls.md - TLS edge recipes (nginx, Traefik, SSH tunnel, WireGuard) and rate limiting at the proxy.
- docs/encrypted-storage.md - encrypting the storage volume with LUKS, ZFS, or BitLocker.
Three Forgejo workflows (.forgejo/workflows/) take a commit from lint to
published artifact. push-checks.yml (every branch push) and pr-checks.yml
(pull requests to release) run the same gates: rustfmt/clippy, the workspace
tests including the v1 + v2 real-client round-trips (node/npm/go must be
present or the job fails), the example-config/compose lint, and the multi-node
cluster suites - followed by the docker job: a registry-published multi-arch
image (linux/amd64 + linux/arm64) on EVERY branch push - release carries
the bare-semver tags 1.0.0/1.0/1 + latest + release read from
Cargo.toml, other branches carry the regexY-style dev scheme
(dev/latest/<version>-dev + truncations, with latest tracking dev
between releases), and PR runs publish a flyweight pr-<number> tag. Both
legs share a buildkit layer cache served by cachette itself (docker buildx
type=gha against the acc.nixthedev.ru cache; an unset
CACHETTE_BUILDX_JWT only means cache misses, never failures). release.yml
runs on v* tags and on pushes to the release branch: it cross-compiles the
four targets (linux/windows x amd64/arm64), writes SHA256SUMS, and attaches
the binaries + checksums to a release tagged v<version> - the pushed tag on a
tag build, or Cargo.toml's package version on a release-branch build.
Merging a feat/* pull request into release therefore fires both pipelines
at once: the image publish and the release cut.
These are intentionally out of scope for this version; cachette does not advertise or require them:
- Chunk-level (content-addressed) deduplication between cache entries.
- Server-side (on-the-fly) compression of blob contents.
- Application-level encryption of blobs (transport is TLS, not the Noise protocol). Encrypt the storage volume instead (see docs/encrypted-storage.md).
- Storage backends other than the local filesystem (no S3 or WebDAV).
- Replication of cache entries between nodes: each entry lives on the single node that owns it.
cachette is MIT licensed - the full text is in LICENSE.