git-cache-proxy
A small, read-only caching proxy for Git repositories. It sits between many clients (CI build/test machines) and an origin git server, serving clones and fetches from a local bare mirror and pulling only the delta from upstream on each request.
It exists for the case where an elastic fleet of ephemeral machines (e.g. autoscaled CI runners in another region/cloud) repeatedly clones large repos over a slow or expensive link to an on-prem origin. Point the fleet at a proxy running next to them and the bulk history is served locally; only new commits cross the WAN, deduplicated across the whole fleet.
Why not just a mirror or an HTTP cache?
- A generic HTTP cache doesn't work:
git fetchis a negotiated pack transfer (POST /git-upload-packresponses are computed per request), so there's nothing static to cache. - A scheduled full mirror (Gitea pull-mirror, etc.) means enumerating and replicating every repo, and it can lag the exact commit CI just pushed.
This proxy is lazy and pull-only: it caches on first request, and on every
request it does an incremental git fetch from upstream before serving - so
a client always gets the ref it asked for, and nothing is ever proactively
pushed or replicated to where the proxy runs. That last property tends to be
the difference between "sure" and "absolutely not" when the origin is sensitive.
How it compares
| Approach | Caches fetch [1] |
Lazy [2] | Fresh ref [3] | Delta-only WAN [4] | Pull-only [5] | Any origin [6] |
|---|---|---|---|---|---|---|
| git-cache-proxy | yes | yes | yes | yes | yes | yes |
| Generic HTTP cache (nginx / Varnish) | no | n/a | no | no | yes | yes |
| Scheduled mirror (Gitea / GitLab pull-mirror) | yes | no | lag | no | no | yes |
git clone --reference / alternates |
n/a | no | seed | yes | yes | yes |
| GitLab Geo / server geo-replication | yes | no | yes | no | no | no |
- Caches the negotiated
git-upload-packresponse, not just static objects - a plain HTTP cache can't, because every fetch is computed per request. - Caches on first request; no repo enumeration or replication schedule to run.
- Serves the exact ref asked for. The proxy fetches upstream before serving; a scheduled
mirror can lag its refresh interval (
lag);--referenceis only as fresh as its local seed (seed). - Only new objects cross the WAN per request; the bulk history is served from the local mirror.
- Never writes to or proactively replicates from the origin - it only ever pulls what a client requested.
- Works against any unmodified Git smart-HTTP origin. GitLab Geo needs GitLab on both ends.
How it works
All git work is delegated to the system git binary, so protocol correctness -
protocol v2, shallow and partial (filtered) clones - comes for free.
GET <repo>/info/refs?service=git-upload-pack
1. resolve <repo> -> upstream URL + local bare mirror dir
2. clone --mirror (first time) or fetch --prune (coalesced per repo)
3. serve `git upload-pack --advertise-refs` from the mirror
POST <repo>/git-upload-pack
-> stream `git upload-pack --stateless-rpc` from the mirror (packfile)
anything git-receive-pack -> 403 (read-only)
Concurrent clients for the same repo are serialized so a burst triggers a single upstream fetch; a short TTL coalesces repeated requests.
git-LFS
LFS objects use a different HTTP API from the git protocol, so they are cached separately:
POST <repo>/info/lfs/objects/batch
-> forward to upstream, then rewrite each object's download URL back to this
proxy so the object fetch is cached here
GET <repo>/info/lfs/objects/<oid>
-> serve from the on-disk cache, or on a miss fetch it from upstream once
(verify sha256 == oid, store), then serve
anything with operation=upload -> 403 (read-only)
Objects are content-addressed and immutable, so a cached object is never stale and
is shared across every repo that references the same oid; the first fetch across the
fleet pays the WAN cost, the rest are served locally. The upstream LFS transfer is
in-process over HTTPS (reqwest + rustls, no OpenSSL), so the binary stays statically
linked. Cached objects share the --cache-max-mb budget and LRU eviction with the
git mirrors. No configuration is needed: LFS is served on the same endpoints the git
client already routes through the proxy.
Benchmark
For a fleet of ephemeral clients fetching the same content, only the first fetch pays the WAN cost; the rest are served locally. Over an emulated 20 Mbit/s, 60 ms-RTT link - cloning a 64 MB repo, and fetching a 32 MB git-LFS object:
| Scenario | Time | WAN bytes |
|---|---|---|
| Clone, direct (today) | 28.1 s | 64 MB |
| Clone, via proxy cold (runner 1) | 28.5 s | 64 MB |
| Clone, via proxy warm (2..N) | 0.6 s | ~0 MB |
| LFS object, cold (runner 1) | 12.8 s | 32 MB |
| LFS object, warm (2..N) | 0.2 s | ~0 MB |
The first runner sees no penalty and every subsequent runner is served in-region
while nothing crosses the WAN; the saving scales with fleet size and link cost.
Reproduce or retune (TOTAL_MB, LFS_MB, RATE_MBIT, RTT_MS) with
bench/run.sh - see bench/README.md for
the method and its caveats.
Install
cargo install builds from source and needs a git binary on PATH at runtime
(all wire-protocol work is delegated to it). Or pull the container image, which
bundles git:
Quick start
# then, on a client:
The insteadOf rewrite is how a client transparently routes through the proxy.
Pair it with pushInsteadOf back to the origin so pushes bypass the (read-only)
proxy:
git config url."http://proxy:8080/".insteadOf "https://git.example.com/"
git config url."https://git.example.com/".pushInsteadOf "https://git.example.com/"
Try it
The image on GHCR runs as-is against any public origin, no config file. Point it at GitHub and clone through it:
# in another terminal - the proxy mirrors the repo on the first request and
# serves it from the local mirror thereafter:
Configuration
Every flag has an environment-variable equivalent.
| Flag | Env | Default | Purpose |
|---|---|---|---|
--bind |
GITCACHEPROXY_BIND |
0.0.0.0:8080 |
Listen address |
--cache-root |
GITCACHEPROXY_CACHE_ROOT |
/var/cache/git-cache-proxy |
Where bare mirrors live |
--upstream |
GITCACHEPROXY_UPSTREAM |
- (required) | Origin git base URL |
--upstream-auth-header |
GITCACHEPROXY_UPSTREAM_AUTH_HEADER |
- | Full HTTP header injected on upstream clone/fetch, e.g. Authorization: Basic <base64> (see Auth model); injected via env so it stays out of argv |
--serve-token |
GITCACHEPROXY_SERVE_TOKEN |
- | If set, clients must send Authorization: Bearer <token> |
--fetch-ttl-seconds |
GITCACHEPROXY_FETCH_TTL_SECONDS |
10 |
Skip upstream fetch if refreshed within this window (0 = always fetch) |
--max-concurrent-requests |
GITCACHEPROXY_MAX_CONCURRENT_REQUESTS |
64 |
Max concurrent in-flight requests; excess queue (0 = unlimited) |
--max-decoded-body-mb |
GITCACHEPROXY_MAX_DECODED_BODY_MB |
512 |
Cap on a decoded upload-pack request body, in MiB (bounds memory / gzip bombs) |
--cache-max-mb |
GITCACHEPROXY_CACHE_MAX_MB |
0 |
Cap on total on-disk mirror cache, in MiB; evicts least-recently-used idle mirrors when exceeded (0 = unlimited, no eviction) |
--git-binary |
GITCACHEPROXY_GIT_BINARY |
git |
Path to git |
Endpoints: /healthz, /readyz, /metrics (Prometheus - per-repo request and
upstream counters, cache-size gauges, LFS object hit/miss counters, and
*_duration_seconds fetch/serve latency histograms).
Auth model
- Upstream: the proxy holds a single read-only credential (
--upstream-auth-header) used only tofetch/clone. It never writes upstream. The value is the full header line and is passed verbatim to git ashttp.extraHeader. If it is unset/blank the proxy warns at startup and contacts upstream anonymously. - Clients: anonymous by default (intended for a network-restricted
deployment); set
--serve-tokento require a bearer token.
GitLab with a personal access token
GitLab's git smart-HTTP endpoint authenticates a PAT via HTTP Basic auth
(the PAT is the password; any non-empty username works) — not Authorization: Bearer (OAuth2 access tokens only) and not the PRIVATE-TOKEN header (REST API
only). Build the header as Basic auth:
Verify it against the git endpoint before wiring up the proxy — a 200 means
the header works, a 401 means the scheme is wrong:
When passing it into a container, prefer -e GITCACHEPROXY_UPSTREAM_AUTH_HEADER
(no =) so an unset variable is dropped with a warning rather than silently
forwarded as an empty string.
Security model
The proxy is a shared, credentialed reader, so a few properties are worth making explicit before you expose it:
- Reachability is the trust boundary. The proxy fetches from upstream with
its own single credential and serves the result to whoever asked. There is no
per-repo authorization: with
--serve-tokenunset it serves anonymously, and when set the token is one shared secret that grants access to everything the upstream credential can read - including repos a given client could not read directly. Restrict who can reach the proxy (private network, security group, mTLS at the ingress) and treat "can reach the port" as "can read every mirrored repo". - Plain HTTP. The proxy speaks HTTP, so a
--serve-tokenbearer travels in cleartext. Terminate TLS in front of it (reverse proxy / ingress) on any network you do not fully trust. Upstream fetches use whatever scheme the--upstreamURL specifies - usehttps://. - Defaults are open. It binds
0.0.0.0:8080and serves anonymously unless--serve-tokenis set. That is deliberate for a locked-down CI network; do not place it on an untrusted one without a token and TLS. - DoS knobs.
--max-concurrent-requestscaps concurrent upstream clone/fetch work and--max-decoded-body-mbbounds request-body memory (defusing a decompression bomb).--cache-max-mbbounds on-disk growth by evicting least-recently-used idle mirrors; it defaults to0(unlimited), so set it - or isolate and monitor the cache volume - on an untrusted network. - Read-only. Only
git-upload-pack(clone/fetch) is served;git-receive-pack(push) is refused and upstream is only ever pulled from, never written.
Deploy
The Dockerfile builds a statically linked (musl) binary and drops it onto a
minimal Alpine base. Because the git wire protocol is delegated to the system git
binary, the runtime image must contain git - so it is Alpine-with-git rather than a
fully distroless/FROM scratch image. (The LFS transfer is in-process, so it adds no
runtime tool - only CA certs.) Removing the git dependency and enabling a git-free
image means moving the git plumbing in-process to a Rust library - see the roadmap
below.
On Kubernetes, a Helm chart lives in chart/ (single-writer
Deployment, /healthz+/readyz probes, cache PVC, optional Ingress and
Prometheus ServiceMonitor):
helm install git-cache-proxy oci://ghcr.io/rolandjitsu/charts/git-cache-proxy \
--set upstream=https://your-git-host.example.com
See the chart README for the full values reference.
Status / scope
Working and end-to-end tested against both Git wire protocol versions — the
modern v2 (git-protocol header, the default since Git 2.26) and the legacy
v0/v1 advertisement — covering full clone, incremental delta fetch, and
push rejection. git-LFS is cached too: the batch API is proxied and objects are
stored content-addressed on disk, covered by the LFS integration tests (miss/fetch/
verify, cache hit, corrupted-object rejection, upload refusal).
This is early, single-maintainer software: no independent review or wide deployment yet. Pin a version and try it against your own setup before you rely on it.
Not yet implemented, in rough priority order:
- A background/scheduled refresh option (today every
info/refstriggers an on-demand, TTL-coalesced fetch). - No external
gitbinary: move the plumbing in-process to a Rust library (gitoxide/git2). More robust (no subprocess/argv surface, structured errors) and unlocks a fully distroless, git-free image - a larger change, tracked as a possible v2.
Contributions welcome.
License
Apache-2.0. Self-contained - no dependencies outside crates.io.