Why git-lfs3
GitHub's built-in LFS meters both storage and transfer, and the free tier is shared across every repository and fork. Backed by Cloudflare R2, git-lfs3 makes transfer free and drops storage to a fraction of the price.
| Metric | GitHub LFS | git-lfs3 + R2 |
|---|---|---|
| Storage | $0.07 / GB·mo | $0.015 / GB·mo |
| Transfer (egress) | $0.0875 / GiB over 10 GiB | Free |
| Free tier | 10 GiB shared, all repos + forks | 10 GB storage · unlimited bandwidth |
| URL latency | ~270 ms | ~20–100 ms · same datacenters |
Latency is low enough to serve whole websites straight from LFS, bypassing the 25 MiB Cloudflare Pages file-size limit.
How it works
git-lfs3 implements the Git LFS Batch API. For each object your client transfers, the worker presigns a time-limited, path-style S3 URL from the credentials in your LFS URL and returns it. Your client then uploads or downloads bytes directly to the bucket.
The worker is stateless. It never stores your credentials and makes no outbound request on your behalf. See SECURITY.md for the full trust model.
Path-segment options
Supply region, service, or sessionToken as key=value segments before the endpoint. service defaults to s3. R2 works with the defaults; Amazon S3 requires the bucket's region.
…@<INSTANCE>/region=auto/<ENDPOINT>/<BUCKET>
Consequences of statelessness
- The worker can't tell whether an object already exists, so
git lfs pushre-uploads every object. An S3PUTof identical content is idempotent, so this is harmless — just extra transfer. - It still returns a signed URL for an object missing from the bucket; the failure surfaces later as an S3 transfer error, not a Git LFS "not found".
- It signs at most 1000 objects per batch; Git LFS chunks larger transfers automatically.
Deploy the proxy
Fork the repository, then create a Cloudflare Pages project connected to your fork with these settings:
| Framework preset | None |
|---|---|
| Build command | npm install --omit=dev |
| Build output directory | / |
--omit=dev installs aws4fetch for Cloudflare's bundler while skipping the local-dev and test tooling, so it isn't uploaded as static assets.
After the first deploy, Cloudflare assigns a *.pages.dev hostname — this becomes your <INSTANCE> in the steps below. You can also attach a custom domain.
EXPIRY environment variable (seconds; default 3600). It is clamped to S3's allowed range of 1–604800 seconds (7 days). Empty, unset, or non-numeric values fall back to the default.Visiting the deployment in a browser shows a short landing page explaining how to point a Git client at it — try lfs.khws.io.
Connect a bucket
On any S3-compatible object store, create a bucket for your LFS assets and an access key with read/write permission. The credential is always a pair: an access key ID such as AKIAIOSFODNN7EXAMPLE and a secret such as wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY. URL-encode either value if it contains non-alphanumeric characters.
Pick a provider for setup links and the endpoint format. R2 has the most generous free tier and shares datacenters with the LFS client worker.
Build the server URL
The LFS server URL packs the credential, instance, endpoint, and bucket into one string:
https://<ACCESS_KEY_ID>:<SECRET_ACCESS_KEY>@<INSTANCE>/<ENDPOINT>/<BUCKET>
It's fiddly to assemble by hand. Fill the fields below and copy the result. Nothing leaves your browser — the builder runs entirely client-side.
Install Git LFS
Check whether Git LFS is already present:
git lfs version
If the command reports git: 'lfs' is not a git command, follow the upstream installation instructions. Then set up the smudge and clean filters for your user account:
git lfs install
Point Git at the proxy
Two approaches, depending on who can push.
Separate read and write keys
The committed .lfsconfig carries a read-only URL; the read/write key reaches each clone out-of-band. Use this for public repos and most private repos with several collaborators. Create a read-only access key, build a URL with it, and commit it:
cd "$(git rev-parse --show-toplevel)" # repository root
git config -f .lfsconfig lfs.url 'https://<RO_KEY>:<RO_SECRET>@<INSTANCE>/<ENDPOINT>/<BUCKET>'
git add .lfsconfig
git commit -m "Add .lfsconfig"
Grant a clone write access by adding a read/write URL to its .git/config. Git never commits .git/config, so the key stays private:
git config lfs.url 'https://<RW_KEY>:<RW_SECRET>@<INSTANCE>/<ENDPOINT>/<BUCKET>'
Shared read/write key
Simpler, less secure: put the read/write URL directly in .lfsconfig. Anyone with a clone can then push.
git config -f .lfsconfig lfs.url 'https://<RW_KEY>:<RW_SECRET>@<INSTANCE>/<ENDPOINT>/<BUCKET>'
git add .lfsconfig
git commit -m "Add .lfsconfig"
Track files with Git LFS
Once Git points at the proxy, use Git LFS as usual. Route a file type through LFS for future commits:
git lfs track '*.iso'
git add .gitattributes
git commit -m "Add .iso files to Git LFS"
Move files already in history into LFS with git lfs migrate (this rewrites history):
# move all existing .iso files
git lfs migrate import --everything --include='*.iso'
# or move every file above 25 MiB
git lfs migrate import --everything --above=25MiB
git push --all --force-with-lease
Migrating existing objects
Only relevant if you already store objects on another LFS server. Fetch every object before changing the LFS URL, then push them to the proxy.
Show migration steps
Before changing the URL, fetch a local copy of everything:
git lfs fetch --all
After pointing Git at the proxy, push every fetched object:
git lfs push --all origin
Development
The worker logic lives in src/; _worker.js is the entry point Cloudflare bundles. Tests run offline because S3 signing is local crypto — no network.
npm install # runtime + dev dependencies
npm test # offline test suite (node --test)
npm run check:bundle # verify the Cloudflare Pages bundle
npm run dev # serve locally with Wrangler