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)
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.