A Git LFS server that runs on Cloudflare Pages and stores objects in any S3-compatible bucket.
Why use it:
- Cheaper than GitHub LFS. GitHub charges $0.0875/GiB transfer above 10 GiB/month across all repos and forks, and $0.07/GB-month for storage. Backed by R2, this proxy makes transfer free and drops storage to $0.015/GB-month.
- Serve assets directly. Latency is low enough to serve whole websites from LFS, which also bypasses the 25 MiB Cloudflare Pages file size limit.
git-lfs3 implements the Git LFS Batch API. For each object your client asks to transfer, the worker presigns a time-limited, path-style S3 URL using the S3 credentials from your LFS URL and returns it; your client then uploads/downloads bytes directly to/from the bucket. The worker is stateless — it never stores your credentials and sends no outbound request on your behalf. See SECURITY.md for the full trust model.
You can supply region/service/sessionToken as key=value path segments before the endpoint (e.g. …@<INSTANCE>/region=auto/<ENDPOINT>/<BUCKET>). service defaults to s3. Cloudflare R2 works with the defaults; Amazon S3 requires the bucket's region (e.g. region=us-east-1).
Because the worker is stateless and never contacts the store, a few behaviors follow:
- It cannot 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). - The worker still returns a signed URL for an object missing from the bucket; the failure surfaces later as an S3 transfer error rather than a Git LFS "not found" message.
- The worker signs at most 1000 objects per batch request; Git LFS chunks larger transfers automatically.
Fork this repository, then create a Cloudflare Pages project connected to your fork:
- Framework preset: None.
- Build command:
npm install --omit=dev(installsaws4fetchfor Cloudflare's bundler;--omit=devskips the local-dev/test tooling so it isn't uploaded as static assets). - Build output directory:
/.
After the first deploy, Cloudflare assigns the project a *.pages.dev hostname (you can also attach a custom domain). This hostname becomes <INSTANCE> in later steps.
To change the signed URL lifetime, set the EXPIRY environment variable (seconds; default 3600). The worker clamps EXPIRY to S3's allowed range of 1–604800 seconds (7 days); empty, unset, or non-numeric values fall back to the 3600-second 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).
On an S3-compatible object store, create a bucket to host your LFS assets and an access key with read/write permission for it. Each provider names the credential pair differently; the links below cover both steps. Options, cheapest first:
- Cloudflare R2 — create a bucket, then an Access Key ID and Secret Access Key.
- Backblaze B2 — create a bucket, then an Application Key ID and Application Key.
- Wasabi — create a bucket, then an Access Key and Secret Key.
- Amazon S3 — create a bucket, then an Access Key ID and Secret Access Key, ideally scoped to a dedicated IAM user.
- Google Cloud Storage — create a bucket, then an HMAC Key Access ID and HMAC Key Secret, ideally scoped to a service account.
- Linode Object Storage — create a bucket, then an Access Key and Secret Key.
- DigitalOcean Spaces — create a bucket, then an Access Key and Secret Key.
R2 has the most generous free tier — 10 GB of storage, 1 million writes and 10 million reads per month, and unlimited bandwidth — and shares datacenters with the LFS Client Worker.
Whatever the provider's labels, the credential is a pair: an access key ID such as AKIAIOSFODNN7EXAMPLE and a secret access key such as wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY. URL-encode either value if it contains non-alphanumeric characters.
The URL template is:
https://<ACCESS_KEY_ID>:<SECRET_ACCESS_KEY>@<INSTANCE>/<ENDPOINT>/<BUCKET>Placeholders:
<ACCESS_KEY_ID>,<SECRET_ACCESS_KEY>: from Create a bucket and access key.<INSTANCE>: the Cloudflare Pages hostname (e.g.lfs.khws.io, or a custom domain).<ENDPOINT>: the S3-compatible API endpoint for your object store.<BUCKET>: the bucket name from Create a bucket and access key.
Example. For an R2 bucket my-site (Cloudflare account ID 7795d95f5507a0c89bd1ed3de8b57061), access key ed41437d53a69dfc:dc49cbe38583b850a7454c89d74fcd51, and proxy at lfs.khws.io, the URL is:
https://ed41437d53a69dfc:dc49cbe38583b850a7454c89d74fcd51@lfs.khws.io/7795d95f5507a0c89bd1ed3de8b57061.r2.cloudflarestorage.com/my-siteCheck whether you already have Git LFS:
git lfs versionIf 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 installFetch a local copy of every existing object before changing the LFS URL:
git lfs fetch --allTwo ways, depending on who can push:
- Separate read and write keys. The committed
.lfsconfigcarries only 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. - Shared read/write key. The committed
.lfsconfigcarries the read/write URL, so anyone with a clone can push. Simpler, less secure.
Create another access key with read-only permission, build a server URL with it, and add the URL to an .lfsconfig file at the root of your repository:
cd "$(git rev-parse --show-toplevel)" # move to root of repository
git config -f .lfsconfig lfs.url 'https://<RO_ACCESS_KEY_ID>:<RO_SECRET_ACCESS_KEY>@<INSTANCE>/<ENDPOINT>/<BUCKET>'
git add .lfsconfig
git commit -m "Add .lfsconfig"To grant a clone write access, add a read/write URL to its .git/config:
git config lfs.url 'https://<RW_ACCESS_KEY_ID>:<RW_SECRET_ACCESS_KEY>@<INSTANCE>/<ENDPOINT>/<BUCKET>'Git does not commit .git/config, so the read/write key stays private.
Put the read/write URL directly in .lfsconfig. Anyone with a clone can then push:
cd "$(git rev-parse --show-toplevel)" # move to root of repository
git config -f .lfsconfig lfs.url 'https://<RW_ACCESS_KEY_ID>:<RW_SECRET_ACCESS_KEY>@<INSTANCE>/<ENDPOINT>/<BUCKET>'
git add .lfsconfig
git commit -m "Add .lfsconfig"After pointing Git at the proxy, push every fetched object to the new server:
git lfs push --all originAfter Git points at the proxy, use Git LFS as usual. A few common operations:
-
Route any
.isofiles added in future commits through Git LFS, withgit lfs track:git lfs track '*.iso' git add .gitattributes git commit -m "Add .iso files to Git LFS"
-
Move all existing
.isofiles into Git LFS, withgit lfs migrate(this rewrites history):git fetch --all git lfs migrate import --everything --include='*.iso' git push --all --force-with-lease -
Move every existing file above 25 MiB into Git LFS (also rewrites history):
git fetch --all git lfs migrate import --everything --above=25MiB git push --all --force-with-lease
See the upstream Git LFS examples for more operations.
npm install # install runtime + dev dependencies
npm test # run the offline test suite (node --test)
npm run check:bundle # verify the worker bundles the way Cloudflare Pages bundles it
npm run dev # serve locally with Wrangler (fetched on demand via npx)The worker logic lives in src/; _worker.js is the entry point Cloudflare bundles. Tests are offline because S3 signing is local crypto (no network).
Much inspiration was retrofitted from twilligon/git-lfs-s3-proxy, a Git LFS proxy for S3-compatible storage that fills the exact same use-case. Thanks to its authors.
MIT © 2026 Kurt Stolle