Git Is All You Need

This short guide will walk you through how I'm hosting my read-only public Git repos using git-http-backend and the cgit web UI.

github error 500

The final code can be found in this repo (hosted using the very method described in this guide). Everything is deployed with Docker to make it portable and easier to install on a new server.

Git bare repo primer

For those who don't know, Git has the concept of a bare repository. This is a Git folder that does not have the working directory and only contains Git's data. This is what you use if you want to create a Git remote repo on a server yourself.

You initialize it on your server with:

git init --bare $REPO_NAME.git

And then you can clone it through SSH with:

git clone $SERVER_URL:$REPO_PATH

After that, everything works the same as if you were cloning a GitHub repo. For a private personal project it is enough to run a bare repo on a remotely accessible server you control. This is what I use for most of my projects. The rest of this guide will explore the case when you also want to publicly expose some projects to anyone on the internet.

Building Caddy with CGI support

I'm using Caddy for the HTTP server mainly because the configuration is easier than NGINX or Apache. Also, Caddy has excellent out-of-the-box TLS termination, and most of the time it just works without any configuration.

Unfortunately git-http-backend and cgit communicate with the HTTP server through the Common Gateway Interface (CGI). It is not supported natively by Caddy, so we need to build Caddy ourselves with the CGI extension. It is easily done with xcaddy.

xcaddy build --with github.com/aksdb/caddy-cgi/v2

git-http-backend

Let's start by exposing the remote repo for Git operations using the HTTP backend. We need to make sure that we are only allowing read operations, otherwise anyone on the internet would be able to modify the repositories. By default, git-http-backend should disable the receive-pack service (receiving modifications from clients), but on my side, I explicitly disable it. Also, because I am running with Docker I can mount the repo directory as a read-only directory as an additional protection.

Note that although we are blocking pushes through the HTTP transport we are still able to push changes using SSH. That way you can only modify the repos if and only if you have SSH access to the server.

Here is a basic Caddyfile config that routes all traffic from the Git User-Agent to the git-http-backend process through CGI.

git.rustymanta.xyz {
  @git {
    header User-Agent git/*
  }

  route {
    handle @git {
      cgi * /usr/libexec/git-core/git-http-backend {
        env GIT_PROJECT_ROOT=/git-repos GIT_HTTP_EXPORT_ALL=1 GIT_HTTP_RECEIVEPACK=0
      }
    }
  }
}

And this is all you need for allowing git clone with HTTP. You could also double-check that doing git push to this remote will lead to a 403 error. Next, let's expose a nice HTTP UI.

Cgit

Cgit is technically not needed. If you want to keep it minimal, you could, for example, just have an HTML page with the cloning URL for your repos. But it is a nice addition, allowing people to browse the code without cloning it. It's also pretty handy when you want to link a commit or file in a discussion.

Like git-http-backend, cgit communicates through CGI. The Caddy route is pretty similar to the previous one, we need one route for the static cgit files (like the CSS file) and one for the dynamic content generated from the Git repos.

handle /cgit.* {
    root * /usr/share/webapps/cgit
    file_server
}

handle {
    cgi * /usr/share/webapps/cgit/cgit.cgi {
        env CGIT_CONFIG=/cgitrc
    }
}

Cgit configuration

I did not spend too much time digging into the cgit configuration options, you can refer to the latest version of my config for an up-to-date, complete version.

I chose not to have cgit auto-discovery the repos and instead add them manually in the cgitrc file. For example, here is the config for a single repo.

repo.url=git_serve
repo.name=Git Serve
repo.path=/git-repos/git_serve.git
repo.desc=Read only dockerized way of sharing some git repos
repo.clone-url=https://git.rustymanta.xyz/git_serve

In addition to the basic config I added a simple email obfuscation script, configured by setting the email-filter variable in cgitrc. Cgit is calling it with the original email in stdin and expects the filtered email in stdout.

IFS= read -r email
printf '%s\n' "$email" \
    | sed \
        -e 's/@/ (at) /' \
        -e 's/\./ (dot) /g'

For the styling I based mine on the Catppuccin theme port created by VidhuKant. The README in VidhuKant's repo also explains how to set up the markdown and code highlighting.

Workflow

This is most of the infra work for hosting public repos. How you use it is mostly up to you, but here is my personal workflow for reference. I use two remotely accessible computers with bare Git repos on them.

  • A private one that I use to sync pretty much all my code-related projects and share code between my computers (accessible only via SSH in my WireGuard VPN).
  • And a public one running the Caddy server.

For most of my projects, my origin remote points to my private remote and, only if I want to publish the code on the internet do I create a new bare repo on the public server and add a public remote to the same project. That way I have full control over when and what I publish.

>>> git remote -v
origin  cupcake:~/Repos/git_serve (fetch)
origin  cupcake:~/Repos/git_serve (push)
public  eclair:~/PublicRepos/git_serve (fetch)
public  eclair:~/PublicRepos/git_serve (push)
git workflow diagram

So far I'm pretty happy with that workflow.

No collaboration?

In that setup, there is no collaboration features or contribution workflow. I am the only one that can push modifications to the repos. That being said, other people could always discuss and send me code patches through email, and that might be enough for most of the projects.

I plan on exploring collaboration infra in the future.