Why git-lfserve
Cloudflare Pages refuses to serve any static file larger than 25 MiB. Video, datasets, disk images and other large assets simply can't ship as ordinary files — Git LFS replaces them in your repository with small text pointers, and Pages serves the pointer, not the file.
git-lfserve runs as Pages middleware. When Pages is about to serve a Git LFS pointer, the worker resolves the object it references and serves the real bytes in its place. Large files stay in LFS; visitors get the file.
| A request for a large asset | Plain Pages | With git-lfserve |
|---|---|---|
| Files under 25 MiB | Served | Served (passes through) |
| Files over 25 MiB | Build fails / 404 | Object served from LFS |
| What the visitor receives | The LFS pointer text | The real bytes |
| Range requests (video seek) | — | 206 Partial Content |
How it works
The worker installs as functions/_middleware.js — a thin entry point over six modules in src/ (handler, pointer, lfsconfig, batch, objects, http). On every GET and HEAD it lets Pages produce the response, reads the first 256 bytes, and checks whether they are a valid LFS pointer — the spec version line, a 64-hex oid and an integer size. Anything that isn't a pointer passes through untouched.
Two ways to resolve an object
- Bound R2 bucket. When
LFS_BUCKETandLFS_BUCKET_URLare both set, the worker reads the object straight from the bound bucket — no round-trip to an LFS server, and full GETs are cached as immutable. - Upstream LFS server. Otherwise it reads the server URL from your
.lfsconfig, calls the LFS batch API for a download action, and streams the object from there.
Range requests
HTTP Range works on both paths, so video seeking and partial downloads behave correctly: the bucket path returns 206 Partial Content straight from R2, and the LFS-server path forwards Range to the object store. Every object response carries Content-Length and Accept-Ranges: bytes.
.lfsconfig URL are forwarded only to the upstream LFS batch endpoint — never stored, never logged. /.lfsconfig is never served, and any resolution failure returns a generic 502 with the cause logged privately. See SECURITY.md.Install Git LFS
Run every command from your repository root:
cd "$(git rev-parse --show-toplevel)"
git lfs version
If the command reports git: 'lfs' is not a git command, follow the upstream installation instructions. The binary doesn't always register the smudge and clean filters, so install them for your user account:
git lfs install
Install the worker
The worker must run as middleware, so _middleware.js needs to become functions/_middleware.js in your repository, alongside a symlink functions/.lfsconfig.txt → .lfsconfig so it can read your LFS configuration.
If your site has no functions directory, the simplest route is to add this repository as a submodule named functions:
git submodule add https://github.com/khwstolle/git-lfserve functions
git commit -m "Add Git LFS Client Worker"
Disable LFS fetching by default
The worker resolves pointers on the fly, so the standard client must not also fetch them when Cloudflare Pages clones your repository — if it does, builds fail at the "Cloning git repository" step even though LFS holds every large file. Disable the filters in .lfsconfig (environment variables don't appear to reach that build step):
git config -f .lfsconfig lfs.fetchexclude '*'
git add .lfsconfig
git commit -m "Disable LFS fetching by default"
After checking out a commit with this change, Git LFS leaves pointers in place. Restore normal behaviour in your own clones by overriding the setting locally:
git config lfs.fetchexclude ''
Choose an LFS server
GitHub and GitLab both run default LFS servers, but neither was built to serve web content — storage and bandwidth are metered and shared, and returning an object's URL alone takes around 270 ms.
Whichever server you use, specify its URL in .lfsconfig. The worker has no other way to find it — a Pages worker doesn't know which repository built its site.
Track large files
At a minimum, route every file above the 25 MiB Pages limit through LFS:
find . -type f '!' -path './.*' -size +25M -exec git lfs track {} +
find . -type f '!' -path './.*' -size +25M -exec git add --renormalize {} +
git add .gitattributes
git commit -m "Add files over 25 MiB to Git LFS"
Track each new large file before committing it, or track a whole type by pattern when it consistently exceeds the limit:
git lfs track bigfile # one file
git lfs track '*.mp4' # a whole type
Create a Pages site
Create a Cloudflare Pages site from your repository, or push to trigger a rebuild. The middleware is picked up automatically from functions/.
git push
Bind an R2 bucket (optional)
If you switched to git-lfs3 backed by R2, binding the bucket to your Pages site lets the worker read objects straight from R2 — skipping the presigned-URL round-trip for the best performance.
LFS_BUCKET | R2 bucket binding — the bucket you created for git-lfs3. |
|---|---|
LFS_BUCKET_URL | Your LFS server URL without the access key: https://<INSTANCE>/<ENDPOINT>/<BUCKET>. |
Show dashboard steps
In the Workers & Pages dashboard, open your Pages site:
- Settings → Functions → R2 bucket bindings → Production — add a binding with variable name
LFS_BUCKETpointing at your LFS bucket. - Settings → Environment variables → Production — add
LFS_BUCKET_URLwith the value above. - Deployments → Production → Manage deployment → Retry deployment — redeploy so the bindings take effect.
With both variables set, the worker fetches objects directly through the binding instead of asking the LFS server for presigned URLs.
Configuration
The worker reads your repository's .lfsconfig (via the .lfsconfig.txt symlink) plus optional Pages environment bindings.
| Key | Default | Purpose |
|---|---|---|
.lfsconfig url | — | Upstream LFS server URL. Resolved in order: [lfs] url, then [remote "origin"] lfsurl, then any other [remote] lfsurl. |
LFS_BUCKET | unset | R2 bucket binding. With LFS_BUCKET_URL, objects are read straight from the bucket. |
LFS_BUCKET_URL | unset | LFS server URL without credentials, used to key the bucket cache. |
KEEP_HEADERS | Cache-Control | Comma-separated headers copied from the original page response onto the served object. |
If the worker can't resolve an object, it logs the cause server-side and returns 502 Bad Gateway.