Adding Git LFS support to Ghost
14/10/2026
How Git LFS works and how I added support of it in Ghost
This blog post is in series with the previously released blog post on Creating a Git Platform. You can try the application at web.ghost.procrastinator.fyi.
One item out of many on the roadmap was to add Git LFS support. Git LFS allows you to store large files such as Audio, Video etc where Git, instead of storing the file, stores a pointer to the file on the server.
The authentication was covered in previous blog post. LFS shares the same spec.
The Git LFS also works over http with the below mentioned endpoints:
| Route | Does |
|---|---|
POST /<owner>/<repo>.git/info/lfs/objects/batch | Which objects to upload, or where to download each |
PUT …/info/lfs/objects/<oid> | Stores one object |
GET …/info/lfs/objects/<oid> | Serves one object |
GET, POST …/info/lfs/locks, …/locks/verify, …/locks/<id>/unlock | Advisory locks |
Inside your .git folder, the LFS objects live at .git/lfs/objects/
POST /info/lfs/objects/batch
This route returns application/vnd.git-lfs+json as its Content-Type which technically is just json. The body looks like below:
class-validator and I personally love it
The response for it looks like this:
In case of errors, the object's actions is replaced with an error: {code, message} field. Errors are per object, so one missing file doesn't fail the whole batch. A download for an object the repository doesn't hold gets a 404, an upload above the size limit gets a 422. And an upload for an object the server already has comes back with no actions at all, which is git-lfs's cue to skip it.
Notice the header in each action. I just copy the request's own Authorization header into it, so git-lfs doesn't have to go look up credentials a second time.
Now based on the command you ran, git will either upload or download the object from the mentioned href with given headers.
PUT …/info/lfs/objects/<oid>
The PUT request is where git-lfs sends the actual bytes of one object. The body is the raw file, Content-Length is required and the oid in the url is the SHA-256 of the file's contents.
The naive version is "stream the body to S3 at lfs/<repositoryId>/<oid>, done". The problem is that the client is telling me the hash, and it could be a malicious actor using some random hash. If I wrote straight to the object's key, anyone with write access could upload garbage under an oid and overwrite the real bytes that another upload already stored.
So an upload is verified:
- Reserve storage quota and insert an
lfs_objectrow, under the account's lock. The row has nouploadedAtyet, so it's just a reservation. - Stream the bytes to a staging key,
lfs/<repositoryId>/uploads/<uploadId>, hashing them on the way. - Only if the hash matches the oid, copy the staging object to
lfs/<repositoryId>/<oid>and setuploadedAt. Otherwise delete the row and fail with a422.
The staging object is deleted in a finally either way.
uploads can never be a valid oid (an oid is 64 lowercase hex chars), so staging and stored objects share the repository's prefix without ever colliding.
A few edge cases:
- Two uploads of the same oid at once. The insert is
onConflictDoNothing(), so the second one gets a409 Conflict. - An upload that died with its process. It leaves a reservation row behind forever. So before inserting, I delete any reservation of the same oid that's older than the reservation TTL.
- The oid becomes part of an S3 key. So it's checked against
/^[0-9a-f]{64}$/before anything else. Only SHA-256 is accepted. - Size. One object is at most 5 GiB, which is S3's limit for a single
PutObject.
GET …/info/lfs/objects/<oid>
The boring one. Look up the row, make sure uploadedAt is set, and stream the object from S3 back with Content-Length set and application/octet-stream as the type. Bytes go through the API both ways and never straight from the bucket, so the same auth applies to everything.
Authentication
The same Basic auth middleware from the previous post authorizes all of these. Uploads and lock changes need write access, downloads need read.
There's one catch. For git's own routes, I can tell a push from a fetch by the path. For LFS, the batch request names its operation (upload or download) in the body. So the batch body has to be parsed before the auth middleware runs:
Over SSH, git-lfs doesn't have a password to send. It runs git-lfs-authenticate <repo> <operation> on the server instead, which prints the HTTPS endpoint and a header to use. Ghost answers with a Bearer token: an HMAC-signed payload of { userId, repositoryId, operation, expiresAt }, good for one hour. The middleware only accepts it on the LFS routes of that one repository, and only for writes if it was issued for an upload.
Locks
Git LFS also has a locking API, for files you can't merge, like a Photoshop file. You git lfs lock design.psd and everyone else knows not to touch it.
| Route | Does |
|---|---|
GET …/locks | Lists locks, filterable by path or id, paginated |
POST …/locks | Locks a path. 409 with the existing lock if it's already taken |
POST …/locks/verify | Before a push: which locks are ours, which are theirs |
POST …/locks/<id>/unlock | Unlocks. Only the owner can, or an admin with force: true |
Locks are rows in an lfs_lock table, with (repositoryId, path) unique. That uniqueness doubles as the pagination cursor: locks are ordered by path, so the next page is just "paths greater than the last one".
They're advisory, same as on GitHub. git-lfs checks them before a push. The server doesn't reject a push that touches someone else's locked file.
Storage
Objects are stored per repository, at lfs/<repositoryId>/<oid>, with one lfs_object row each. An object is readable exactly where its repository is.
I did think about a shared content-addressed store. Same oid, same bytes, so why keep two copies? Because:
- deleting anything would need reference counting, and
- anyone who knows an oid could claim it in their own repository without ever uploading it. Knowing a hash shouldn't be enough to read the file.
The cost is copying. Forking copies every object of the source repository (server side with S3's CopyObject, so the bytes never leave the bucket), billed to the fork. Merging a pull request from a fork copies only the objects that are named by pointer files in the head's new commits and that the base doesn't already have. Finding those is a git rev-list --objects over the new commits, filtered to blobs under 1025 bytes (a pointer file is capped at 1024), then parsing each blob as a pointer:
On the web
Without any of this, viewing an LFS file in the browser would show you the three lines above. So the blob and raw endpoints check if a file is a pointer, and if the repository holds the object, serve the object instead. They also say lfs: stored | missing | null, so the UI can tell you when the pointer is there but nobody pushed the actual file.
That's it. git lfs install, git lfs track "*.mp4", push, and it just works.