When I first set foot on the internet in the late 2000s and into the early 2010s, the landscape I discovered was one dramatically different from anything that exists today. It was a hideous constellation of little websites owned by everyone with $5 to their name for web hosting, and enough intellectual curiousity to make it through the Wordpress (or competitor) installation. When I tell you that I have never before or since seen quite so much lime green, cyan, hot pink, and neon yellow used together in one place, nor a similar amount of Comic Sans text in any manner of shapes, usages, and directions, that statement would pass a polygraph test. Nevertheless, in all its aesthetically challenged glory, it was beautiful. It was art. In the same way that it's art when your five-year-old draws the trees and the sun and the grass and the sky in a bunch of unintelligible crayon squiggles, and you hang it on your fridge anyway. It was the unrestrained self-expression of a hundred million unique souls, who thought perhaps someone out there might be listening.
Over the next decade, as I grew up, something very depressing happened. All those colorful and creative individual sites were slowly compressed into a knotted, soulless, gray-and-offwhite ball of Roboto in various letter spacings. It stopped being interesting to ask people where they went on the internet, because the internet was not a place to be explored any longer. It was a 4-wide loop through Tiktok, Instagram, Reddit, and MAYBE Substack IF you're lucky. Joe Random began to believe his voice was too small to be found among millions of others, and thought he might have better luck donating it to a centralized platform and spending his five dollars on a gumball.
The Old Way
Now, as much irreparable harm as that particular development has done to the social fabric of, at minimum, the entire United States, we will have to save that commentary for a future take, because the harm along another vertical has somehow managed to outpace it. You see, back when Joe Random had Joe Random's blog at joerandom.com, if Joe Random happened to be a software engineer or developer or whatever else of the kind, you could most likely find the projects Joe was working on at ftp.joerandom.com. Thanks to the centralization of the internet, however, Joe now writes on Substack and can't afford to keep joerandom.com due to the highly inflated price of gumballs. So, where does the infamous Joe Random host his highly coveted code? Well, fortunately, another centralized platform cropped up to solve that particular problem: GitHub. And to its credit, GitHub was very good for many years. It was useful, stable, and even added some fun gamification and social elements to coding, which were cute even if secondary. So, all the Joe Randoms, myself included, moved our projects over to GitHub. And, in the background, web browsers quietly lost the ability to even browse FTP. I mean, why maintain it? Nobody used it anymore.
And then, after extincting the Taxi Cab, they Uber'd us. First, there was GitHub Copilot, and that was bad enough. They said, "since we own all the code now, we're going to train an LLM on it and sell that as a product." If you've ever worked on an open source project you'd be well within your rights to be upset already. That had to be a world record for breaking the most number of software licenses at once, since absolutely zero of the output code is licensed or restricted in any way (and in fact is presented as if it were GitHub's own original work). But, there are also people who could pretty much not care less about license agreements or even enjoy Copilot as a tool. What I guarantee there are zero people who enjoy, however, is the fact that GitHub no longer bothers to upkeep their service. I have been using GitHub for work for as long as I've had jobs, and let me tell you: ever since day 1 of the year of our lord 2026, that shit has been nothing but two-eyed Mike Wazowksi. And by that I do mean:
Does he look amused? Yeah neither am I. Let's take a quick look at the
real GitHub status page.
That's right, they lie so much on their OFFICIAL status page that a third party
had to start independently tracking them to figure out what was going on. And if
you care to click on the little "All Time" tab, you'll notice their WORST 90
DAY PERIOD IN HISTORY ended on May 7 of this year, with a staggering
84.31% uptime. For those of you who don't like math, that means GitHub was
down 15.69% (a little over 1/7) of the time during this period. It's probably
not fair to say that this downtime was exclusively during my work hours, because
15.69% all-hours downtime would translate to spending 65.90% of the work week,
or 26 out of the 40 hours, unable to use source control properly. But, being
that my work schedule aligns EXACTLY with when the GitHub developersvibe
coders (derogatory) were pumping AI slop into production, I don't think it would
be unfair to say that this winter and spring it did feel like there was some
GitHub-related bullshit blocking me, on at least one task, at least 10 hours out
of every week. In a sane world that statement alone would be enough to pump the
brakes on vibe coding. Since we live in AI psychosis world instead, it's time to
pump the brakes on GitHub altogether.
The New Way
That's ok though. Surely there are plenty of GitHub competitors that can do the exact same job and aren't on some psychotic vibe coding death spiral that will ultimately ruin their reputation and lead to bankruptcy, like GitLa...
You actually can't make this shit up.
Now, in fairness, there are projects like Codeberg out there, which exist for this exact reason. But, Codeberg is community infrastructure funded by donors, and these resources are best spent on open source projects that offer value to the software community at large--not your innovative new startup idea, which is definitely not the 12th incarnation of YikYak. Some or all of that sentiment may be codified in their terms of service, but I haven't read them so I wouldn't know.
So where, then, should you host the world's future most popular anonymous discourse platform and its definitely-not-vibe-coded source and will definitely IPO for ten billion dollars? The answer is actually quite simple. You see, if you can't sell your source code to The Devil (Microsoft), and you can't leech off community resources to host it, you can actually just... host it yourself. It's kind of like the olden days of a federated internet, except instead of an ugly-but-endaring FTP interface, you can instead have a snazzy full-featured UI with all the GitHub features you've grown accustomed to, for the low low price of the cheapest VPS you can find. I can probably find one cheaper than you because I've been using the internet since before I had money. And by that I don't mean that I had less money, I mean that I had zero money, and zero way to procure money, because my age was in the single digits and my parents didn't approve of what I was doing. But, don't let that dissuade you. If you have a spare computer or computer-esque device lying around, you can even do it for free. Let's review the options.
Building a "Git Server"
This is cool because it follows what the Linux kernel does, and the Linux kernel is invariably more successful than you, especially if you spend time listening to me yap. The trick here is that git actually handles most of the heavy lifting behind things like GitHub and GitLab, these are basically just frontends and IAM systems. And, backend engineer propaganda incoming: the funny thing about frontends is, nobody needs them--they're just a crutch.
The first thing we're going to need is a server. That usually costs money. I've gone through a lot of VPS providers in my time, and I'm currently a big fan of SSD Nodes or CrunchBits, as they're both very cheap and highly reliable. CrunchBits will be better if you have highly CPU-bound needs or kind of a non-standard VPS package (weird RAM vs. disk vs. CPU ratios, need multiple IPs, stuff like that), SSD Nodes will be better bang for your buck if you don't care and just need a box to run your stuff. Despite the lack of uptime SLAs or any of that sweaty tryhard corporate shit, I've yet to encounter an instance of downtime from either, unlike a certain cloud source control platform. But, if you have legal skin in the game, definitely do your own research. Alternatively, most people have a laptop in a drawer somewhere, that can work too.
Definitely also set up ufw and fail2ban so your server doesn't get hacked. I
leave that as an exercise for the reader. You will also need to open ports 22,
80, and 443 for normal git stuff to work, and also basically anything else you'd
ever want to do with a server. It's just ufw allow [port]. Do this before you
enable ufw so you don't get boned.
Once we've got our server, we're going to throw together 3 pieces: git, the GOAT web server (NGINX), and a bunch of bash scripts that act like an IAM system. The first part of this is easy, just install stuff. On Debian you can do:
apt install git gnupg nginx letsencrypt fcgiwrap
If you're not on something Debian-based, that's a you problem.
Already this will kind of work via SSH. You can make a repo with:
git init --bare folder.git
and then clone it using:
git clone [email protected]:folder.git
In fact, that's basically the complete answer for how I'd recommend handling what GitHub would call "private repositories"--stuff you don't want other people to see or edit. But, for things you DO want other people to see, SSH doesn't support anonymous connections (or maybe it does and nobody ever turns that on because it's an apocalyptically bad idea). That's what HTTP(S) is for (yes, the ONLY thing it's for.)
First, we need a place where these "public repositories" can go. The private
ones were in your user's home directory, but you probably don't want to expose
that to the web, so let's just make some shit up, like /var/git.
Anyway:
mkdir /var/git
Now we're ready to go, so let's open up /etc/nginx/sites-available/default and
make it say:
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name git.joerandom.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2 default_server;
listen [::]:443 ssl http2 default_server;
include snippets/snakeoil.conf;
root /var/git;
server_name git.joerandom.com;
autoindex on;
default_type text/plain;
location / {
try_files $uri $uri/ =404;
}
}
Now we can reload nginx with:
nginx -t && nginx -s reload
Now we've got something called "dumb HTTP" going. This will technically do everything we want, except that you need to enable a git hook in all your repos to make it work, and it's just worse in every way than "smart HTTP", so we're just going to brush over this and move on to the better alternative. Basically, dumb HTTP is slower, clunkier, consumes far more disk space and bandwidth, and doesn't support push operations.
Luckily, git ships a backend for HTTP that handles all this out of the box, we just have to have nginx hand off control to it using FastCGI.
Let's update our /etc/nginx/sites-available/default to read:
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name git.joerandom.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2 default_server;
listen [::]:443 ssl http2 default_server;
include snippets/snakeoil.conf;
root /var/git;
server_name git.joerandom.com;
autoindex on;
default_type text/plain;
# vvv adding this block vvv
location ~ \.git/(info/refs|git-upload-pack|git-receive-pack) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/lib/git-core/git-http-backend;
fastcgi_param PATH_INFO $uri;
fastcgi_param GIT_PROJECT_ROOT /var/git;
fastcgi_param GIT_HTTP_EXPORT_ALL 1;
fastcgi_param GIT_HTTP_RECEIVE_PACK 0;
fastcgi_pass unix:/run/fcgiwrap.socket;
}
# ^^^ end ^^^
location / {
try_files $uri $uri/ =404;
}
}
That new location block sets up the CGI handoff to git using the FastCGI socket, which should be running already since we installed it earlier.
The astute among you may notice that fastcgi_param GIT_HTTP_RECEIVE_PACK 0
line. That means that push operations are still disabled over HTTP on your git
server, and will return a 403 to the user. This is technically the default
behavior, but with a big asterisk attached--this option defers to the individual
repo config level if you omit the environment variable, and it's not altogether
uncommon for individual repos to have weird gitconfig setups, especially if you
have a lot of them or they're controlled/created by multiple people. So, to
avoid opening up your server to anonymous pushes, we're going to force this off
globally. If you want, you can set up HTTP authentication and force this on
globally instead (change the 0 to a 1), but I think SSH is the better way
to handle write access since it doesn't require prompting the user for a
username and password every time, so I'll leave the HTTP auth stuff as another
exercise for the reader.
Again, reload the server with:
nginx -t && nginx -s reload
Now, the last thing to do before we get up and running is to get an SSL certificate from LetsEncrypt, so your browser doesn't show a big nasty error when you visit the site over HTTPS. This is pretty easy:
letsencrypt certonly --webroot -w /var/git -d git.joerandom.com
We should also set up a cron job to auto-renew this, so use:
crontab -e
And then add this line (The numbers are made up minute/hour numbers, respectively. You are encouraged to change them.):
27 3,15 * * * /usr/bin/letsencrypt renew --agree-tos && /usr/sbin/nginx -s reload
This will put a nice juicy SSL cert at
/etc/letsencrypt/live/git.joerandom.com/fullchain.pem, and ensure that it
stays up-to-date automatically so we don't have a Manjaro situation.
Now we just need to let nginx know where to find it. So:
mkdir /etc/nginx/snippets/ssl
echo "ssl_certificate /etc/letsencrypt/live/git.joerandom.com/fullchain.pem;" >/etc/nginx/snippets/ssl/git.joerandom.com.conf
echo "ssl_certificate_key /etc/letsencrypt/live/git.joerandom.com/privkey.pem;" >>/etc/nginx/snippets/ssl/git.joerandom.com.conf
And now we can edit /etc/nginx/sites-available/default to say:
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name git.joerandom.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2 default_server;
listen [::]:443 ssl http2 default_server;
include snippets/ssl/git.joerandom.com.conf; # <<< edited this line
root /var/git;
server_name git.joerandom.com;
autoindex on;
default_type text/plain;
location ~ \.git/(info/refs|git-upload-pack|git-receive-pack) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/lib/git-core/git-http-backend;
fastcgi_param PATH_INFO $uri;
fastcgi_param GIT_PROJECT_ROOT /var/git;
fastcgi_param GIT_HTTP_EXPORT_ALL 1;
fastcgi_param GIT_HTTP_RECEIVE_PACK 0;
fastcgi_pass unix:/run/fcgiwrap.socket;
}
location / {
try_files $uri $uri/ =404;
}
}
and hit a quick:
nginx -t && nginx -s reload
Et voila, we are up and running with the simplest possible working git server.
Anonymous users can pull stuff via HTTPS at
https://git.joerandom.org/whatever.git, and you can push stuff via SSH at
[email protected]:/var/git/whatever.git.
Adding a Git User
While that setup works, it's a bit unglamorous to prepend all your repository
paths with /var/git/, and if you want to have multiple users with push access,
it breaks down pretty quickly. You could add a bunch of users with shell
access, and then use git shared repositories for this, but it's a bit tricky.
The more common solution is to just have everyone access git operations by
sharing a user account called git, which has limited permissions and no shell
access.
We can make this user by doing:
adduser --system --shell /usr/bin/git-shell --gecos 'Git Version Control' --group --disabled-password --home /var/git git
And add their authorized keys, which controls who can access the account, with:
mkdir /var/git/.ssh
chmod 0700 /var/git/.ssh
touch /var/git/.ssh/authorized_keys
chmod 0600 /var/git/.ssh/authorized_keys
To avoid turning your git server into a free proxy, it's important to add these
restrictions to the end of your SSH Daemon config, at /etc/ssh/sshd_config:
Match User git
PasswordAuthentication no
AllowAgentForwarding no
AllowTcpForwarding no
X11Forwarding no
GatewayPorts no
PermitTunnel no
and then restart it with:
systemctl restart sshd
Next, let's make sure the file permissions are set correctly in the git directory:
chown -R git:git /var/git
And we're off to the races.
From now on you should create new repositories with:
sudo -u git git init --bare /var/git/whatever.git
to avoid missing up the file permissions.
You can grant push access to whoever you need by adding their ssh keys to the authorized_keys
file, which we created earlier. For example, to authorize a GitHub user you
know, you can do:
curl -s https://github.com/${GITHUB_USERNAME}.keys >>/var/git/.ssh/authorized_keys
To my knowledge, GitHub, GitLab, Codeberg, and every other major source control platform supports this format, including corporate self-hosted instances. Just change the domain name ot whatever's applicable.
Fine-Grained Access Control
The glaring flaw with the solution above is, of course, that everyone with push access can push to every repository. Unless you run a very small organization, that is most likely not ideal. So, this is where we get to controlling access using git hooks. Git hooks are basically just bash scripts you can configure to run at different points in the git lifecycle, which can gatekeep whether the operation succeeds or not. To build an IAM system out of this, we need to accomplish two tasks: identifying who is connecting (since everyone shares the git user), and then determining whether the connecting user should be able to push to the repository they are attempting to push to.
To accomplish the first, we need to make another change to our sshd config
(/etc/ssh/sshd_config). Add:
PermitUserEnvironment GIT_REMOTE_USER
and then restart with:
systemctl restart sshd
What this allows us to do is control an environment variable in our
authorized_keys file based on the key that's connecting. On a side note, make
sure to keep this setting only to the specific environment variables you're
actively using, if you turn PermitUserEnvironment fully on there are crazy
privelege escalation attacks that can be done on your server.
Now, we need to edit our /var/git/.ssh/authorized_keys file to add the
appropriate user identifiers. These are not real usernames on your server so you
can make them whatever you want. Each line in authorized_keys should be
prefixed with a:
environment="GIT_REMOTE_USER=whatever-user-identifier-you-want"
For me, the end result looks like:
environment="GIT_REMOTE_USER=cflems" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ4WPzp/GgjIe++THIo6iNcPjebYfyOgx/hXG0s7EZk6 openpgp:0x9696E912
which means that when I connect with my normal SSH key, the GIT_REMOTE_USER
variable is automatically set to cflems. And since the git user doesn't have
shell access, I can't change it.
Now, to enforce access, we just need a list of these identifiers which are
allowed to push to each repository, and a hook to enforce this policy. I propose
we use a file called "access" in the root of each repository for this purpose.
This file will look like:
/var/git/whatever.git/access:
joe-random
cflems
bob
alice
with exactly one identifier per line.
We can then enforce the policy with this git hook, which we'll put at
/var/git/whatever.git/hooks/pre-receive:
#!/bin/bash
reject() {
echo "error: user has no write access"
exit 1
}
# reject anonymous users
if [[ -z "$GIT_REMOTE_USER" ]]; then
reject
fi
GIT_DIR=$(realpath -- "$GIT_DIR" 2>/dev/null)
if [[ ! -d "$GIT_DIR" ]]; then
reject
fi
# reject unconfigured repos
ACCESS_FILE="$GIT_DIR/access"
if [[ ! -f "$ACCESS_FILE" ]]; then
reject
fi
if ! grep -Fqx -- "$GIT_REMOTE_USER" "$ACCESS_FILE"; then
reject
fi
If you have multiple repos, you may prefer to keep this hook in a central
location, e.g. /var/git/hooks/pre-receive, and then make a symlink to it when
you create a repo with:
ln -s /var/git/hooks/pre-receive /var/git/whatever.git/hooks/pre-receive
With this, we have all the functionality most people will ever need from a git server.
Adding a Frontend
If you want the visual convenience of a frontend, for doing things like showcasing the current state of the repo or linking to specific commits to show the diff, there's a very lightweight CGI script called cgit that I recommend for this purpose.
All we need to do is install it with:
apt install cgit
and then configure /etc/cgitrc:
#
# cgit config
# see cgitrc(5) for details
root-title=Joe Random's Git Server
root-desc=Git repositories hosted by Joe Random
favicon=https://joerandom.com/logo.svg
css=/static/cgit.css
logo=https://joerandom.com/logo.svg
virtual-root=/
clone-prefix=https://git.joerandom.com
source-filter=/usr/lib/cgit/filters/syntax-highlighting.sh
snapshots=tar.gz
enable-http-clone=0
enable-git-config=1
scan-path=/var/git
That will get you a pretty decent setup off rip, but if you're interested you
can look into man 5 cgitrc or search the web for more configuration options.
After that, we just point nginx to cgit by editing
/etc/nginx/sites-available/default one more time:
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name git.joerandom.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2 default_server;
listen [::]:443 ssl http2 default_server;
include snippets/ssl/git.joerandom.com.conf;
root /var/git;
server_name git.joerandom.com;
# vvv added: serve cgit static assets vvv
location /static/ {
alias /usr/share/cgit/;
expires max;
}
# ^^^ end ^^^
location ~ \.git/(info/refs|git-upload-pack|git-receive-pack) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/lib/git-core/git-http-backend;
fastcgi_param PATH_INFO $uri;
fastcgi_param GIT_PROJECT_ROOT /var/git;
fastcgi_param GIT_HTTP_EXPORT_ALL 1;
fastcgi_param GIT_HTTP_RECEIVE_PACK 0;
fastcgi_pass unix:/run/fcgiwrap.socket;
}
# vvv added: route letsencrypt challenges normally vvv
location /.well-known/ {
try_files $uri $uri/ =404;
}
# ^^^ end ^^^
# vvv replaced: route all fallthrough traffic to cgit vvv
location / {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/lib/cgit/cgit.cgi;
fastcgi_param PATH_INFO $uri;
fastcgi_pass unix:/run/fcgiwrap.socket;
}
# ^^^ end ^^^
}
Now we do one more nginx refresh:
nginx -t && nginx -s reload
and we've got ourselves a cute little clone of git.kernel.org. I've got this running at git.cflems.net as well.
Private Repos
If you don't want to do the easy thing and have private repositories sit in the home directory of their owners (maybe you need shared private repositories, for instance), there's a way to bash that out as well (ba dum tss).
First, let's establish how private and public repositories are distinguished.
Currently, our git server is serving every repo indiscriminately over HTTPS, so
let's change that by updating /etc/nginx/sites-available/default to read:
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name git.joerandom.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2 default_server;
listen [::]:443 ssl http2 default_server;
include snippets/ssl/git.joerandom.com.conf;
root /var/git;
server_name git.joerandom.com;
location /static/ {
alias /usr/share/cgit/;
expires max;
}
location ~ \.git/(info/refs|git-upload-pack|git-receive-pack) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/lib/git-core/git-http-backend;
fastcgi_param PATH_INFO $uri;
fastcgi_param GIT_PROJECT_ROOT /var/git;
# removed: fastcgi_param GIT_HTTP_EXPORT_ALL 1;
fastcgi_param GIT_HTTP_RECEIVE_PACK 0;
fastcgi_pass unix:/run/fcgiwrap.socket;
}
location /.well-known/ {
try_files $uri $uri/ =404;
}
location / {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/lib/cgit/cgit.cgi;
fastcgi_param PATH_INFO $uri;
fastcgi_pass unix:/run/fcgiwrap.socket;
}
}
Now, rather than exporting everything, the HTTPS daemon will only allow access
to repos which contain a file called git-daemon-export-ok. This is a weird
choice, but it's what the authors of git have decided, so who are we to question
it? We can define a "public" repo as one that contains a file with this name at
its root, and a "private" repo is one that does not. By default, everything will
start out as private, and then you can do
touch /var/git/whatever.git/git-daemon-export-ok
for each one you want to make public.
Our next order of business is to stop cgit from rendering private repos on the
home page, as unauthenticated users should not have any notion they exist at
all. To achieve this, update /etc/cgitrc like so:
#
# cgit config
# see cgitrc(5) for details
root-title=Joe Random's Git Server
root-desc=Git repositories hosted by Joe Random
favicon=https://joerandom.com/logo.svg
css=/static/cgit.css
logo=https://joerandom.com/logo.svg
virtual-root=/
clone-prefix=https://git.joerandom.com
source-filter=/usr/lib/cgit/filters/syntax-highlighting.sh
snapshots=tar.gz
enable-http-clone=0
enable-git-config=1
# added v
strict-export=git-daemon-export-ok
# added ^
scan-path=/var/git
It's important to add this above scan-path=/var/git, or else it won't apply to
that scan path (which contains all of our repos, so that's bad).
Now we've got a world where anonymous users can't read private repos via HTTPS, but authenticated users can still read them over SSH, even if they aren't authorized to do so. To fix this, we're going to write a gatekeeper script that runs instead of git-shell when the git user connects, and kills the connection if they don't have permission to see what they're trying to access.
The first step is updating the Match User git block at the bottom of
/etc/ssh/sshd_config:
Match User git
PasswordAuthentication no
AllowAgentForwarding no
AllowTcpForwarding no
X11Forwarding no
GatewayPorts no
PermitTunnel no
ForceCommand /var/git/gatekeeper # add this line
and restarting the ssh daemon with:
systemctl restart sshd
Next we need to change the git user's shell so that this command can actually
run and not be intercepted by git-shell. Our script will intercept and
sanitize the user's initial connection attempt anyway, so we no longer need a
safety blanket--we can just use bash.
chsh -s /bin/bash git
Next we need the contents of our /var/git/gatekeeper script:
#!/bin/bash
if [[ "$SSH_ORIGINAL_COMMAND" =~ ^(git-receive-pack|git-upload-pack|git-upload-archive)\ (.+)$ ]]; then
REPO_LITERAL="${BASH_REMATCH[2]}"
REPO_NAME=$(echo "$REPO_LITERAL" | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//")
REPO_DIR=$(realpath -- "$HOME/$REPO_NAME" 2>/dev/null)
if [[ "$REPO_DIR" != "$HOME"/*.git || ! -d "$REPO_DIR" ]]; then
exit 1
fi
if [[ ! -f "$REPO_DIR/git-daemon-export-ok" ]]; then
ACCESS_FILE="$REPO_DIR/access"
READERS_FILE="$REPO_DIR/readers"
if [[ -z "$GIT_REMOTE_USER" ]] || ! grep -Fqx -- "$GIT_REMOTE_USER" "$ACCESS_FILE" "$READERS_FILE" 2>/dev/null; then
exit 1
fi
fi
exec git-shell -c "$SSH_ORIGINAL_COMMAND"
fi
exit 1
And ensure the gatekeeper script is executable with:
chmod +x /var/git/gatekeeper
This will test the SSH_ORIGINAL_COMMAND environment variable, which contains
the command the git client wants to execute, for a valid git command against a
valid git repo. If the command is invalid, the repo isn't in the right place,
or the connecting user doesn't have read access to it, the connection will hang
up instead of executing the intended behavior.
Similar to how we controlled write access, read access is controlled by a list
of usernames in a file called readers at the repository's root. So, if I want
to give my friend bobrandom read access to whatever.git, I would do:
echo bobrandom >>/var/git/whatever.git/readers
With that, we have all the cool backend features of a first-class source control platform, and none of that icky frontend bloat.
Building a "Forge"
If administration via terminal is not one of your hobbies, you can instead rely on the generosity of hard-working strangers at one of the many self-hosted GitHub competitors out there to get the job done. All of these will come with all the features we just home-rolled, plus a nice web UI for all kinds of IAM and administration, user self-registration/login, pull requests and reviews, repository creation and management, and even CI/CD runners comparable to GitHub Actions. Plus, they have nice-to-haves like pushing over HTTPS, which we could have home-rolled, but chose not to out of lethargy.
These platforms make feature parity with GitHub a priority, and there are one-click importers for every last thing under the sun, so even if you're a decently-sized organization currently relying on The Devil (M$) for your source control needs, the switch should be relatively painless (and save you a ton of money, because I promise you the compute to run a git server and a little web app on top does NOT cost $10/user/month).
So far I've had success with:
- Gitea - Looks and feels like GitHub. Is free, private, and reliable unlike GitHub.
- ForgeJo - Gitea but with strong (and correct) opinions about free software, and a buttery smooth install process.
My experience installing Gitea was a bit clunky, and I had originally intended to write a guide around it. However, I later stumbled upon ForgeJo (which is the backend behind Codeberg), and it seems they've gone out of their way to streamline this workflow. So, instead of reading my takes on this matter, go read theirs.
I've got ForgeJo running at https://cflems.dev/ as well, if you'd like to see it in action.