A git forge is a large piece of software. Gitea, Forgejo and GitLab all give you issues, pull requests, accounts and CI, and all of them want hundreds of megabytes of memory to do it.

If what you want is somewhere to git clone from that is not GitHub, you need none of that. git already ships a server. nginx already speaks CGI. The whole thing is two packages and one location block.

What you get, and what you do not

You get git clone, git fetch and git push over your own domain, on your own machine. That is enough for redundancy, for a mirror, and for repositories you do not want on somebody else’s host.

You do not get a web interface, issues, pull requests, or CI. To read the code, you clone it. If that is a problem, this is the wrong tool and you want a forge.

The shape of it

client
  |
  |  https://example.com/git/thing.git
  v
nginx  --(CGI over a unix socket)-->  fcgiwrap  -->  git-http-backend
                                                          |
                                                          v
                                                 /var/lib/git/thing.git

git-http-backend is the server. It ships with git and is already on your machine at /usr/lib/git-core/git-http-backend. It speaks CGI, and nginx does not, so fcgiwrap sits between them and translates.

Install and create the repositories

apt install fcgiwrap
systemctl enable --now fcgiwrap.socket

mkdir -p /var/lib/git
git clone --mirror https://github.com/you/thing.git /var/lib/git/thing.git

A mirror clone is bare and carries every ref, which is what a server wants. Use git init --bare instead for a repository that starts here.

One file decides whether a repository is published:

touch /var/lib/git/thing.git/git-daemon-export-ok

git-http-backend serves a repository only if that file exists. This is a better switch than it looks: a repository without it is still there, still pushable over SSH, and simply invisible over HTTP. Removing the file unpublishes a repository without touching nginx.

The nginx block

# Push goes over SSH, so refuse it here outright rather than leaving it
# to repository config. Regex locations match in order, so this must come
# before the block below.
location ~ ^/git/.*\.git/git-receive-pack$ {
    return 403;
}

location ~ ^/git(/.*\.git.*)$ {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME /usr/lib/git-core/git-http-backend;
    fastcgi_param GIT_PROJECT_ROOT /var/lib/git;
    fastcgi_param PATH_INFO $1;
    fastcgi_pass unix:/run/fcgiwrap.socket;
    fastcgi_read_timeout 300s;
}

Two details in there matter.

PATH_INFO must be the path inside GIT_PROJECT_ROOT, which is why the location captures it. A request for /git/thing.git/info/refs has to reach the backend as /thing.git/info/refs, or every clone returns 404.

The pattern requires .git in the path. That is what lets a site keep its own pages under the same prefix - /git/ and /git/thing/ are static pages and never reach the backend, while /git/thing.git/... always does. Matching all of ^/git/ instead swallows the pages and answers them from a CGI that has no idea what they are.

Push over SSH, not over HTTPS

HTTP push means either an open server or HTTP basic auth, which means a password in a URL or a credential helper. You already have SSH with a key. Use it:

git remote add self ssh://[email protected]:22/var/lib/git/thing.git
git push self main

Nothing new to store, nothing to leak, and the return 403 above means the web side stays read-only even if a repository’s config disagrees.

The part that will cost you an hour

git refuses to operate in a repository owned by another user. This is CVE-2022-24765 hardening and it is on by default. The message is clear enough on its own:

fatal: detected dubious ownership in repository at '/var/lib/git/thing.git'

The trap is that two different users need in. nginx runs the backend as www-data. Your pushes arrive as your own user over SSH. Whichever one owns the files, the other is refused.

The documented fix does not work for the web half:

git config --system --add safe.directory /var/lib/git/thing.git

git-http-backend changes directory into the repository before doing anything, and then reports its path as .. No path entry can match ., so the exception is ignored and the clone still fails - with the same message, now naming . instead of the repository:

fatal: detected dubious ownership in repository at '.'

So the web user has to satisfy the check outright, by being the owner. The SSH user gets in the other way, because SSH always hands git an absolute path and an exception does match:

# www-data owns them; your user reaches them through the group.
chown -R www-data:you /var/lib/git
find /var/lib/git -type d -exec chmod 2775 {} +   # setgid: a push keeps the group
find /var/lib/git -type f -exec chmod g+rw {} +

# For the SSH side, one entry per repository, in your own config.
for d in /var/lib/git/*.git; do
    git config --global --add safe.directory "$d"
done

The setgid bit is not decoration. Without it, objects written by a push land in your own group and the web user eventually cannot read them.

One more thing that wastes time: safe.directory does not glob the way you expect. On git 2.43 a value of /var/lib/git/* matches nothing. Only exact paths work, or a bare *, which switches the protection off everywhere and is not worth it.

Quick reference

SymptomCause
500 on clone, dubious ownership ... at '.'Web user is not the owner. chown to it.
dubious ownership at an absolute path over SSHMissing safe.directory entry for your user.
404 on every clonePATH_INFO is not the path inside GIT_PROJECT_ROOT.
Clone works, repository not listedMissing git-daemon-export-ok. That is the switch.
Site’s own pages return CGI errorsLocation matches all of ^/git/. Require .git.
Push over HTTPS rejectedIntended, if you added the return 403.
safe.directory ignoredGlobs do not work. Use exact paths.

Verifying it

Clone it from somewhere that is not the server, as nobody:

git clone https://example.com/git/thing.git
git ls-remote https://example.com/git/thing.git HEAD

Compare that hash against the origin it mirrors. Then push a throwaway branch over SSH, confirm it is visible over SSH, confirm it is not visible over HTTPS if you meant it not to be, and delete it again.

git push self HEAD:refs/heads/probe
git ls-remote self probe
git push self --delete probe

What this does not solve

Mirrors go stale. Nothing here pulls from upstream on a schedule, so a mirror is only as current as the last time you ran the clone update. A timer calling git remote update in each repository fixes that in about five lines.

It is also not a backup on its own. A mirror on one machine, of repositories on another machine, protects against one of those two machines. It does not protect against you deleting a branch and pushing the deletion to both.