PhantomKnigh287

Gurpal Singh

@PhantomKnight287

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:

RouteDoes
POST /<owner>/<repo>.git/info/lfs/objects/batchWhich 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>/unlockAdvisory 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:

export const LFS_OID = /^[0-9a-f]{64}$/;

export class LfsObjectSpecDTO {
  @Matches(LFS_OID)
  oid: string;

  @IsInt()
  @Min(0)
  size: number;
}

export class LfsBatchRequestDTO {
  @IsIn(['download', 'upload'])
  operation: 'download' | 'upload';

  @IsOptional()
  @IsIn(['sha256'])
  hash_algo?: 'sha256';

  // git-lfs sends 100 at a time
  @ArrayNotEmpty()
  @ArrayMaxSize(1000)
  @ValidateNested({ each: true })
  @Type(() => LfsObjectSpecDTO)
  objects: LfsObjectSpecDTO[];
}
Yea yea ik 'Classes HAHA LOOK AT BOOMER' but nestjs defaults to class-validator and I personally love it

The response for it looks like this:

// this is if you want to download the object(s)
{
  "transfer":"basic",
  "hash_algo":"sha256",
  "objects":[
    {
      "oid":"<oid>",
      "size":"size in bytes",
      "actions":{
        "download":{
          "href":"path where the git should send request to download lfs object",
          "header":"what header should git send at the url. Used for authentication"
        }
      }
    }
  ]
}
// this is for uploading the object(s)
{
  "transfer":"basic",
  "hash_algo":"sha256",
  "objects":[
    {
      "oid":"<oid>",
      "size":"size in bytes",
      "actions":{
        "upload":{
          "href":"path where the git should send request to upload lfs object",
          "header":"what header should git send at the url. Used for authentication"
        }
      }
    }
  ]
}

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:

  1. Reserve storage quota and insert an lfs_object row, under the account's lock. The row has no uploadedAt yet, so it's just a reservation.
  2. Stream the bytes to a staging key, lfs/<repositoryId>/uploads/<uploadId>, hashing them on the way.
  3. Only if the hash matches the oid, copy the staging object to lfs/<repositoryId>/<oid> and set uploadedAt. Otherwise delete the row and fail with a 422.

The staging object is deleted in a finally either way.

const staging = lfsUploadKey(repository.id, uploadId);
const hash = createHash('sha256');
// A listener beside the upload's own reader, not a transform in front of it:
// `putStream` has to see the request's errors to abort.
body.on('data', (chunk: Buffer) => hash.update(chunk));
try {
  await this.s3.putStream({ Key: staging, Body: body, ContentLength: size });
  const actual = hash.digest('hex');
  if (actual !== oid) throw new LfsObjectMismatchError(oid, actual);
  await this.s3.copyObject({
    Bucket: this.s3.bucket,
    Key: lfsObjectKey(repository.id, oid),
    CopySource: `${this.s3.bucket}/${staging}`,
  });
} catch (error) {
  await this.db.delete(schema.lfsObject).where(mine);
  throw error;
} finally {
  await this.s3.deleteObject({ Bucket: this.s3.bucket, Key: staging });
}

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 a 409 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:

const isPush =
  req.path.endsWith('/git-receive-pack') ||
  req.query.service === 'git-receive-pack' ||
  req.method === 'PUT' ||
  (req.method === 'POST' && req.path.includes('/info/lfs/locks')) ||
  req.body?.operation === 'upload';

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.

RouteDoes
GET …/locksLists locks, filterable by path or id, paginated
POST …/locksLocks a path. 409 with the existing lock if it's already taken
POST …/locks/verifyBefore a push: which locks are ours, which are theirs
POST …/locks/<id>/unlockUnlocks. 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:

version https://git-lfs.github.com/spec/v1
oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393
size 12345

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.