Configuring Git over HTTP with Caddy

As part of my plan in self-hosting more and more services and applications, I recently set up a Git server to host my software and research projects. I had a look at Gitea, Gogs and GitLab but ultimately decided that they were just too much for my purposes. This server is just for me and my private projects and I don't need all the features they offer. Eventually I just settled on plain old pushing and pulling over SSH and set up cgit, an attractive but minimalist Git web frontend.

The next part of the plan is to run my own build server so that all my private projects are continuously built and tested. For this, I wanted read-only access the the repositories. The easiest thing to do would have been to setup git daemon and call it a day, but as a stickler for productive procrastination, I was determined to go down the Git over HTTP route. There also wasn't an easy way to configure the git daemon NixOS module so that it only listens on the Tailscale interface without hardcoding the IP address and one day I might publish my Git server to a wider audience.

I use the Caddy web server as it is simple to configure and does automatic HTTPS for Tailscale connections. There the two HTTP protocols for Git: the smart HTTP protocol and the backwards-compatible dumb HTTP protocol. cgit actually provides the dumb protocol with no configuration but this protocol is rather inefficient. The smart protocol requires a CGI program that figures out what the client has and needs, and generates a custom packfile for them. Fortunately as I had already set up cgit I had already done the hard work configuring fastcgi, and just needed to tell Caddy which paths to forward to the Git CGI program.

Caddy Configuration

Unfortunately the official Git documentation is only written for Apache and Lighttpd but it wasn't that difficult to convert into Caddy. For each repository, some subpaths (for example <repo>/HEAD and <repo>/git-upload-pack) need to be forwarded to the CGI program. This was easy enough using Caddy's path_regexp matcher.

@git path_regexp "^.*/(HEAD|info/refs|objects/(info/[^/]+|[0-9a-f]{2}/[0-9a-f]{38}|pack/pack-[0-9a-f]{40}\.(pack|idx))|git-upload-pack)$"
handle @git {
  reverse_proxy unix//run/git-cgi.socket {
    transport fastcgi {
      env SCRIPT_FILENAME ${pkgs.git}/libexec/git-core/git-http-backend
      env GIT_HTTP_EXPORT_ALL 1
      env GIT_PROJECT_ROOT /tank/code/git
    }
  }
}

I always find Caddy's URL for unix sockets somewhat ugly and I can never remember the format. Note that without the GIT_HTTP_EXPORT_ALL environment variable only repositories that have been marked with the magic file git-daemon-export-ok will be exported.

If you want to be able to push over HTTP then you also need to forward requests from git-receive-pack too. Ideally you would want some form of authentication first so you would need a separate handler that checks that the user is who they should be before reverse proxying to Git. I found the naming of git-upload-pack and git-receive-pack slightly confusing: they are to be interpreted from the perspective of the server not the client. As I want to allow pulls but not pushes then the server must be able to upload but not receive files.

I tested this and it worked! But we can do better.

Accelerated Caddy Configuration

But this is rather inefficient: for each response we are reverse proxying from git-http-backed to Caddy via FastCGI, and then Caddy is serving this via HTTP. But for static content, the bulk of a git pull, we don't need to do this. They are just files on a disk and we can ask Caddy to serve them directly!

Fortunately this was pretty easy too. Based on the accelerated static Apache config in the documentation, I split the above regular expression so that the repository's objects can just be served as-is by Caddy and the remaining dynamic content is proxied to git-http-backend.

@git_cgi path_regexp "^.*/(HEAD|info/refs|objects/info/[^/]+|git-upload-pack)$"
@git_static path_regexp "^.*/objects/([0-9a-f]{2}/[0-9a-f]{38}|pack/pack-[0-9a-f]{40}\.(pack|idx))$"

handle @git_cgi {
  reverse_proxy unix//run/git-cgi.socket {
    transport fastcgi {
      env SCRIPT_FILENAME ${pkgs.git}/libexec/git-core/git-http-backend
      env GIT_HTTP_EXPORT_ALL 1
      env GIT_PROJECT_ROOT /tank/code/git
    }
  }
}

handle @git_static {
  file_server {
    root /tank/code/git
  }
}