Skip to main content
GIT LFS
Background image by Elchinator from Pixabay. Git LFS logo by GitHub/Atlassian, MIT.

This post is part of a series on building dev-random.me, covering how this site was built and continues to evolve.

Part of the motivation for building this site was to build a self hosted photo gallery. I had considered several open source options that involved server side components that replaced something like Google Photos, but I wanted something that was more simple and “static” in nature, at least for public albums. I had been meaning to rebuild my main site with Hugo for a while and start blog posting again, something I hadn’t done in many years, and started researching if it’s possible to build a photo gallery with Hugo — spoiler: it is. In this blog post, I won’t be discussing how I built that photo gallery, at least not yet, but instead will be discussing how I decided to deal with tracking all the very large files I now have in my git repo for dev-random.me.

Why Git LFS? Why Self-Hosted? #

That’s a great question, but first what is Git LFS? I could have easily hosted the larger RAW image files and web friendly images elsewhere and just linked to them, removing the need to solve this problem, but that option wasn’t apealing to me for several reasons:

  • I needed to host these files somewhere, why not along side the image gallery?
  • Hugo provides a lot of built-in functionality for working with Resources1 (and specifically image2 files) associated with a page. If the files were external, I’d lose some of those benefits.

This meant I could build a workflow to export an album from Lightroom and then import that album with a bash script that could also extract some metadata (title, tags), create the correct directory structure for the album, and create an index.md file with the proper front matter. Once I had established this workflow though, it was clear I would need to figure out how to self-host LFS since I would quickly blow through the GitHub 10GiB free tier and I didn’t want to pay for hosting fees. I’ve also been trying to move as many things to self hosted as possible, since I have the setup at home to do as much (future blog post series comming on that topic). So I started doing some research on different options for hosting Git LFS.

So Why Rudolfs? #

Well it wasn’t the only consideration, I also looked at Giftless, which is quite feature rich, but most of the features I wouldn’t use, and it’s also written in python so I knew it wouldn’t be as performant as it could be and would use more resources then it needed. I also considered Gitea, which is a full self hosted Git solution that includes LFS support, which I still might come back to someday, but I’m not ready to move to a self hosted Git just yet. That’s when I stumbled on Rudolfs, a barebones LFS server written in Rust, with support for File and S3 backed storage. It does not support authentication out of the box, but it can be used behind a reverse proxy to provide auth, which is exactly what I did. It’s already available as a docker container as well, which made it very easy to write a simple deployment and deploy it on my local Kubernetes cluster.

A Quick Note on How Git LFS Works #

This part took me a while to debug, because initially I turned on Nginx’s support for basic auth and kept getting stuck at actually uploading any files. It turns out the way Git LFS works is it sends a request to /objects/batch with the information about what it wants to do and the Authorization header is set as part of this request 3. But, it’s up to the Git LFS server to tell the client what headers to set on subsequent requests.

sequenceDiagram
    participant Client as Git LFS Client
    participant LFS as LFS Server
    participant Storage as Storage Backend

    Client->>LFS: POST /objects/batch
    Note right of Client: Accept: application/vnd.git-lfs+json
Content-Type: application/vnd.git-lfs+json
Authorization: Basic/Bearer (optional) LFS-->>Client: 200 OK Note left of LFS: Content-Type: application/vnd.git-lfs+json
Body includes:
actions.upload.href
actions.upload.header Client->>Storage: PUT {upload.href} Note right of Client: Content-Type: application/octet-stream
+ headers from upload.header
(e.g., Authorization, x-amz-*) Storage-->>Client: 200 OK opt Verify action provided Client->>LFS: POST {verify.href} Note right of Client: Accept: application/vnd.git-lfs+json
Content-Type: application/vnd.git-lfs+json LFS-->>Client: 200 OK end

While Rudolfs does not support authentication, it does support reflecting back the Authorization header from the initial request to /objects/batch in the response so that whatever auth was set will continue to be sent for each object4. Unfortunately, by default, there’s not an easy way to get Ngninx Ingress to forward the Authorization header to the backend without making some changes to the nginx-config. So we’ll need to make some changes to the Nginx Ingress configuration to make this all work.

Deploying Rudolfs on Kubernetes with Nginx Ingress #

I’m going to be making a number of assumptions here based on my own setup. These steps assume that you’re running:

  • Ingress NGINX Controller to manage ingress
  • cert-manager to provission TLS certificates
  • Have DNS setup and are able to point new domains at your Ingress NGINX Controller
  • Have durable local storage available

Note

It is possible to point Rudolfs directly at S3 and enable encryption, but neither of these options will be covered here. Refer to Rudolfs’ documentation5 for more information if you’re interested in those options.

Enabling Nginx Ingress Configuration Snippets #

You might already have this in your settings, but to properly forward the Authorization header to Rudolfs you’ll need to make sure that allow-snippet-annotations is enabled — which allows setting config snippets via ingress annotations6, and annotation-risk-level is Critial — which allows for the specific config snippet we need to set7.

config-map.yaml
1apiVersion: v1
2kind: ConfigMap
3metadata:
4  name: nginx-config
5  namespace: kube-system
6data:
7  allow-snippet-annotations: "true"
8  annotations-risk-level: Critical

Create a Namespace for Rudolfs #

If you don’t already have a namespace to run Rudolfs, I recommend creating one. Running everything in default is generally not a good idea8.

Create new namespace
kubectl create ns git-lfs

Creating Basic Authentication Credentials #

Next we need an htpasswd file for Nginx9 that will be stored as a Kubernetes secret. This will create a file in your PWD named auth for the user admin.

Generate LFS htpasswd
htpasswd -c auth admin
Create Kubernetes Secret from htaccess file
kubectl -n git-lfs create secret generic basic-auth --from-file=auth

Deploying on Kubernetes #

Next we’ll deploy Rudolfs as a Deployment, pointed at a local directory. I’m assuming you’ve already created this path and know how to do so — for my setup I have a single node Kubernetes cluster with a local ZFS raid array, so I created a filesystem specifically for Rudolfs which is mounted to /storage/git-lfs.

deployment.yaml
 1apiVersion: apps/v1
 2kind: Deployment
 3metadata:
 4  name: rudolfs
 5  namespace: git-lfs
 6spec:
 7  replicas: 1
 8  selector:
 9    matchLabels:
10      app: rudolfs
11  template:
12    metadata:
13      labels:
14        app: rudolfs
15    spec:
16      containers:
17        - name: rudolfs
18          image: "jasonwhite0/rudolfs:latest"
19          args:
20            - "--cache-dir"
21            - "/data"
22            - "--port"
23            - "8080"
24            - "local"
25            - "--path"
26            - "/data"
27          ports:
28            - containerPort: 8080
29          volumeMounts:
30            - name: lfs-storage
31              mountPath: /data
32      volumes:
33        - hostPath:
34            path: /storage/git-lfs
35            type: ""
36          name: lfs-storage

Creating a Service #

Then you’ll need to create a service to expose Rudolfs’ HTTP server to the cluster.

service.yaml
 1apiVersion: v1
 2kind: Service
 3metadata:
 4  name: rudolfs
 5  namespace: git-lfs
 6spec:
 7  selector:
 8    app: rudolfs
 9  ports:
10    - port: 80
11      targetPort: 8080

Exposing via Ingress #

And then expose it outside the cluster via Nginx Ingress. All the annotations here are important for things to work smoothly and securely. Here’s a breakdown of what each annoataion means:

AnnotationValueDescription
cert-manager.io/cluster-issuerletsencryptTells cert-manager to use the letsencrypt issuer, adjust to whatever your issuer is. If you don’t use cert-manager, you can omit this field.
nginx.ingress.kubernetes.io/proxy-body-size“0”Allows unlimited body size since Git LFS will be sending large blobs10.
nginx.ingress.kubernetes.io/proxy-read-timeout“600”Allow for downloads to take a long time, make this value bigger if you have issues with downloads timing out11.
nginx.ingress.kubernetes.io/proxy-send-timeout“600”Allow for uploads to take a long time, make this value bigger if you have issues with uploads timing out11.
nginx.ingress.kubernetes.io/auth-typebasicEnable HTTP basic authentication12.
nginx.ingress.kubernetes.io/auth-secretbasic-authReference to the secret we created earlier12.
nginx.ingress.kubernetes.io/auth-realm"Authentication Required"Description of the protected area, required for basic auth, and displayed in the error message if authentication fails13.
nginx.ingress.kubernetes.io/configuration-snippetSee BelowAdds additional configuration to the ingress14.

And a break down of the configuration snippet:

KeyValueDescription
proxy_set_headerAuthorization $http_authorization;Sets the Authorization header of the request to the backend to $http_authorization15 16.
ingress.yaml
 1apiVersion: networking.k8s.io/v1
 2kind: Ingress
 3metadata:
 4  name: rudolfs
 5  namespace: git-lfs
 6  annotations:
 7    cert-manager.io/cluster-issuer: letsencrypt
 8    nginx.ingress.kubernetes.io/proxy-body-size: "0"
 9    nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
10    nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
11    nginx.ingress.kubernetes.io/auth-type: basic
12    nginx.ingress.kubernetes.io/auth-secret: basic-auth
13    nginx.ingress.kubernetes.io/auth-realm: "Authentication Required"
14    nginx.ingress.kubernetes.io/configuration-snippet: |
15      proxy_set_header Authorization $http_authorization;
16spec:
17  rules:
18    - host: lfs.example.com
19      http:
20        paths:
21          - path: /
22            pathType: Prefix
23            backend:
24              service:
25                name: rudolfs
26                port:
27                  number: 80
28  tls:
29    - hosts:
30        - lfs.example.com
31      secretName: lfs-tls

Note

Make sure to replace lfs.example.com with the domain you’re actually using.

Testing with cURL #

Now you should be able to successfully connect using basic auth.

$ curl -u admin -v https://lfs.example.com/api/
Enter host password for user 'admin':
* Host lfs.example.com:443 was resolved.
* too many IP, can't show
*   Trying 192.168.1.10:443...
* Connected to lfs.example.com (192.168.1.10) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/certs/ca-certificates.crt
*  CApath: none
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES128-GCM-SHA256 / x25519 / RSASSA-PSS
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=lfs.example.com
*  start date: Nov 25 03:58:45 2025 GMT
*  expire date: Feb 23 03:58:44 2026 GMT
*  subjectAltName: host "lfs.example.com" matched cert's "lfs.example.com"
*  issuer: C=US; O=Let's Encrypt; CN=R13
*  SSL certificate verify ok.
*   Certificate level 0: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
*   Certificate level 1: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
*   Certificate level 2: Public key type RSA (4096/152 Bits/secBits), signed using sha256WithRSAEncryption
* using HTTP/2
* Server auth using Basic with user 'admin'
* [HTTP/2] [1] OPENED stream for https://lfs.example.com/api/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: lfs.example.com]
* [HTTP/2] [1] [:path: /api/]
* [HTTP/2] [1] [authorization: Basic <REDACTED>]
* [HTTP/2] [1] [user-agent: curl/8.8.0]
* [HTTP/2] [1] [accept: */*]
> GET /api/ HTTP/2
> Host: lfs.example.com
> Authorization: Basic <REDACTED>
> User-Agent: curl/8.8.0
> Accept: */*
>
* Request completely sent off
< HTTP/2 400
< date: Sun, 30 Nov 2025 23:40:11 GMT
< content-length: 26
< strict-transport-security: max-age=31536000; includeSubDomains
<
* Connection #0 to host lfs.example.com left intact
Missing org/project in URL%

Adding to Your Git Repo #

Now you should finally have a working Git LFS server! You should be able to add it to your Git repo’s .lfsconfig17:

.lfsconfig
[lfs]
    url = https://lfs.example.com/api/org/repo

And that’s it! That’s how I was able to solve the issue of all the large files associated with this site using Git LFS and my self hosted file server.


  1. "Page Resources - Hugo Documentation". Accessed November 30, 2025. [Archived]  ↩︎

  2. "Image Processing - Hugo Documentation". Accessed November 30, 2025. [Archived]  ↩︎

  3. "Git LFS Batch API". Accessed November 30, 2025. [Archived]  ↩︎

  4. "rudolfs/src/app.rs - GitHub". Accessed November 30, 2025. [Archived]  ↩︎

  5. "Running It - Rudolfs README". Accessed November 30, 2025. [Archived]  ↩︎

  6. "allow-snippet-annotations - Ingress-Nginx Controller". Accessed November 30, 2025. [Archived]  ↩︎

  7. "annotations-risk-level - Ingress-Nginx Controller". Accessed November 30, 2025. [Archived]  ↩︎

  8. "The Importance of Kubernetes Namespace Separation - KubeOps". Accessed November 30, 2025. [Archived]  ↩︎

  9. "Module ngx_http_auth_basic_module - nginx.org". Accessed November 30, 2025. [Archived]  ↩︎

  10. "Custom max body size - Ingress-Nginx Controller". Accessed November 30, 2025. [Archived]  ↩︎

  11. "Custom timeouts - Ingress-Nginx Controller". Accessed November 30, 2025. [Archived]  ↩︎ ↩︎

  12. "Authentication - Ingress-Nginx Controller". Accessed November 30, 2025. [Archived]  ↩︎ ↩︎

  13. "HTTP Authentication Realm - MDN Web Docs". Accessed November 30, 2025. [Archived]  ↩︎

  14. "Configuration snippet - Ingress-Nginx Controller". Accessed November 30, 2025. [Archived]  ↩︎

  15. "proxy_set_header - nginx.org". Accessed November 30, 2025. [Archived]  ↩︎

  16. "Embedded Variables ($http_*) - nginx.org". Accessed November 30, 2025. [Archived]  ↩︎

  17. "Client Configuration - Rudolfs README". Accessed November 30, 2025. [Archived]  ↩︎

Mentions

Respond to this post on your own site. If you do, send me a webmention here. Find out more about webmentions on the IndieWeb.

No mentions yet. Be the first to respond!