Git LFS server / Rust / MPL-2.0 / FerrLabs

Your assets. Your disk.

LFSX stores the large binaries of a Git repository, game assets, textures, models and video, so they never touch your host's LFS quota.

A 3 GB asset pack cloned by a CI job ten times a month is 30 GB of metered traffic.

GitHub bills LFS storage and bandwidth separately from your plan, and a Unity or Unreal project burns through the free tier in a single push. Self-hosting removes the meter entirely: the cost becomes a disk you already own.

What actually changes

GitHub / GitLab LFS

Storage and bandwidth metered, billed in packs

Objects sit with the same vendor as the repository

Access control is the forge's, and so is the bill

A CI job pulling the same pack pays for it every time

LFSX

A disk you already own, with no meter on it

The repository is unchanged, and only the transfer is redirected

Still the forge's permissions, asked live, with no second user list

One committed .lfsconfig, and clients need no plugin

Quick start

Run it

A container, a statically linked binary from the releases, or cargo install lfsx-server if you would rather compile.

docker run -d --name lfsx \
  -p 8080:8080 \
  -v lfsx-data:/var/lib/lfsx \
  -e LFSX_PUBLIC_URL=https://lfs.example.com \
  ghcr.io/ferrlabs/lfsx:latest

Point a repository at it

The last two path segments are the organisation and the project, and together they scope the storage. Two repositories sharing a URL share their objects.

# .lfsconfig
[lfs]
	url = https://lfs.example.com/my-org/my-project

Then use Git LFS as usual

git lfs install
git lfs track "*.psd"
git add .gitattributes assets/hero.psd
git commit -m "add hero artwork"
git push

Run git lfs install before cloning. Without it, files arrive as 130-byte pointer stubs, and the tools that read them, Unity, Unreal and image editors, fail in confusing ways.

Verify it works

The doctor checks the server is up, its storage is writable, your token is accepted, and that the URL it advertises for transfers is the one you reached it on, which is the mismatch that lets negotiation succeed while every transfer fails.

npm install -g @ferrlabs/lfsx        # or: cargo install lfsx
lfsx --url https://lfs.example.com doctor --repo my-org/my-project

There are no accounts to manage.

A client presents the token it would use to clone over HTTPS, and LFSX asks the forge what that token is allowed to do. Each answer is cached, so a push of two hundred objects costs one API call rather than two hundred.

GitHubGitLabGitea, ForgejoObjects
admin Maintainer, Owner admin download, upload, and force a lock open
push Developer push download, upload, and take locks
pull only Reporter pull only download
none Guest, or none none rejected

In CI, the token the workflow already holds is enough, with no secret to provision.

- run: |
    printf 'protocol=https\nhost=lfs.example.com\nusername=git\npassword=%s\n' "${{ secrets.GITHUB_TOKEN }}" \
      | git credential approve
    git lfs pull

Configuration

All configuration is by environment variable. There is no config file to template and no database to migrate.

LFSX_BIND
listen address, 0.0.0.0:8080
LFSX_STORAGE_ROOT
root of the object store, /var/lib/lfsx
LFSX_PUBLIC_URL
public URL used to build transfer links
LFSX_AUTH
permission source, github or gitlab or gitea, or disabled
LFSX_STORAGE
s3 to keep objects in a bucket instead of on the volume
LFSX_COMPRESSION
zstd:1 to zstd:19, to compress objects at rest
LFSX_ENCRYPTION_KEY_FILE
32-byte keys as hex, to encrypt objects at rest
LFSX_REPO_QUOTA
bytes a single repository may hold, unlimited

LFSX_PUBLIC_URL is echoed in the batch response and the client reconnects to it for every object. If it is wrong, negotiation succeeds and every transfer then fails.

Every variable

Self-hosting

Docker

docker run -d -p 8080:8080 -v lfsx-data:/var/lib/lfsx ghcr.io/ferrlabs/lfsx:latest

Kubernetes

helm install lfsx oci://ghcr.io/ferrlabs/charts/lfsx --set ingress.host=lfs.example.com

The chart encodes what is easy to get wrong: the public URL derived from the ingress host, the nginx annotations that keep large uploads from being rejected, probes on /health and /ready, and a refusal to render more than one replica unless the objects are in a bucket.

Binary

curl -fsSL .../lfsx-server-x86_64-unknown-linux-musl.tar.gz | tar xz && ./lfsx-server

x86_64 or aarch64, musl or gnu. Every archive ships a .sha256 next to it.

Cargo

cargo install lfsx-server

Storage

Objects live on the filesystem by default, addressed by digest. A bucket, compression and encryption are each one environment variable, and they compose.

Objects in a bucket

LFSX_STORAGE=s3

An S3-compatible bucket, MinIO, Garage, Backblaze or AWS, instead of the volume, which is what unties capacity from one machine.

The locks move with the objects, taken with a conditional write so the store itself decides who arrived first, and that is what makes a second replica possible. The server checks at startup that the store really performs it. Transfers stream through the server by default; LFSX_S3_PRESIGN=true redirects them to the bucket instead.

Compression

LFSX_COMPRESSION=zstd

The received wisdom is that an LFS store is already compressed, and for PNG, MP3 and OGG that is true. It is badly wrong for meshes.

Objects are compressed in four-megabyte frames with an index, so ranges still work and memory stays flat. Anything that will not compress is stored as it arrived.

.tga

10.4×

.fbx

2.9-6.7×

.png

1%

Measured on two real Unity projects: 71% smaller overall.

Encryption at rest

ChaCha20-Poly1305

For most self-hosted deployments the better answer is the volume: LUKS, an encrypted EBS volume, a storage class that does it transparently. Reach for this when the storage itself is what you do not trust: a shared NAS, a bucket somebody else operates, a disk you will one day return under warranty.

The key is a file path, never the key itself: a key in an environment variable is in the pod spec, in docker inspect, and in every log that dumps the environment. Rotation is a new line at the top of the file. Compression runs first when both are on.

It does not protect against anyone who has the running server, because that process holds the key by construction.

LFSX_STORAGE=s3
LFSX_S3_ENDPOINT=https://s3.example.com
LFSX_S3_BUCKET=assets
LFSX_S3_ACCESS_KEY=…
LFSX_S3_SECRET_KEY=…
LFSX_COMPRESSION=zstd        # level 3
LFSX_COMPRESSION=zstd:9      # slower, smaller

head -c 32 /dev/urandom | xxd -p -c 64 > /etc/lfsx/key
LFSX_ENCRYPTION_KEY_FILE=/etc/lfsx/key
How a bucket deployment works

FAQ

Do my clients need a plugin?

No. Git LFS 3.0.2 and later are exercised on Linux, macOS and Windows, including the copies bundled by Git for Windows, GitHub Desktop, Sourcetree, Rider and Unity.

Can authentication live in the proxy?

No, and this rules out the approach most people reach for first. Behind an authenticating proxy, git-lfs calls the transfer URLs with no credentials at all and retries in a loop. LFSX never claims a transfer through itself is pre-authenticated, so the client authenticates each one.

Which forges are supported?

GitHub, GitLab, and Gitea together with Forgejo, including Enterprise and self-managed instances through their API URL. GitLab grants inherited from a group count the same as ones set on the project.

Does it handle file locks?

Yes, for the assets that cannot be merged, with force-open reserved for the levels a forge treats as administrative: admin on GitHub and Gitea, Maintainer or Owner on GitLab. In a bucket the server checks at startup that the store really refuses a conditional write, and gives locking up rather than hand the same lock to two people.

Can I contribute a forge?

Three providers exist, so the shape is settled rather than guessed: one module under server/src/auth/ exposing three functions, plus a config variant and three arms in auth.rs. The caching, the challenge handling and the rejection accounting are shared and provider-blind.

Self-hosting removes the meter entirely.

LFSXMPL-2.0FerrLabslfsx.dev