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
| Symptom | Cause |
|---|---|
500 on clone, dubious ownership ... at '.' | Web user is not the owner. chown to it. |
dubious ownership at an absolute path over SSH | Missing safe.directory entry for your user. |
404 on every clone | PATH_INFO is not the path inside GIT_PROJECT_ROOT. |
| Clone works, repository not listed | Missing git-daemon-export-ok. That is the switch. |
| Site’s own pages return CGI errors | Location matches all of ^/git/. Require .git. |
| Push over HTTPS rejected | Intended, if you added the return 403. |
safe.directory ignored | Globs 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.
