A Simple Self-Hosted Git Server

If you're writing code for any purpose, I sincerely hope you are using some kind of source control. I personally am only really familiar with Git, and as such it is my go-to tool for keeping track of my code. Nowadays, there are plenty of hosts for pushing your code to some remote server to keep it in a safe place, but what if you don't want to do that for some reason? Hosting your own git server is certainly an option, and isn't even all that complicated. In this post, I'm going to detail my home git server setup, which is probably quite a bit simpler than most people's home git server setups, but it suits me fine as I'm the only one who uses it. If you're looking for a more complete solution with a web interface (I'll come back to this point in another post), advanced user management controls, or support for Git LFS, then the solution I propose here won't be sufficient for you, and I'd instead advise you to take a look at some other self-hosted options like GitLab or Gitea, both of which are excellent options that I myself have used in the past on my home server. As for me, I'm content with just the basics over SSH.

Before we get started, I'll go ahead and cite my source for the inspiration for this setup, the Pro Git book by Scott Chacon and Ben Straub. There's a lot of great content in there, and it's totally free, so I highly recommend checking it out. I'm also going to be using a simple Debian 9 docker image for the tutorial, and I'd recommend attempting to set up the git server in a similar fashion, using a throway VPS or virtual machine, before attempting to set up a real server to be used as a git server. A simple Raspberry Pi is most likely sufficient for this, though I've yet to test that myself. Without further ado, let's begin.

Setting up the server

I'll start by firing up my test server. I've simply mapped port 22 on my local machine to port 22 on the Docker container for convenience, but you could map any port you so desire. Note that if you wanted to keep using the docker container afterwards and you wanted to persist any of the data you plan to push to the server, you'd need to set up volumes. Also note that using Docker for this is entirely unnecessary, and I don't actually use it myself on my home git server.

$ docker run -p 22:22 -it debian:9 /bin/bash

Once that's ready, we can start setting things up. I like to follow the convention that larger hosts such as GitHub have set by using git as the SSH user. I won't get into setting up HTTP(S) access in this tutorial, as I myself only use git over SSH.

# useradd -md /home/git git

In case your unfamiliar with the above command, it's just adding a user called git and creating and assigning a home directory for it at /home/git. This will be the user we connect to the server with, so we can run commands like git clone git@my-server. With the git user created, we'll need to set up SSH access.

# mkdir /home/git/.ssh
# chmod 700 /home/git/.ssh

OpenSSH is a bit particular about the access for the ~/.ssh directory, thus it'll need to be set to 700, or read/write/execute for the owner of the directory, and no access for anyone else. Next we'll need the authorized_keys file.

# touch /home/git/.ssh/authorized_keys
# chmod 600 /home/git/.ssh/authorized_keys

Remember to set the permissions for the authorized_keys file as well, to 600, or read/write for the owner, and nothing for anyone else. If you've done this all as the root user, don't forget to make the owner git.

# chown -R git:git /home/git

Then, using vim, nano, emacs, echo, or whatever you fancy, add your public key to the /home/git/.ssh/authorized_keys file. As a side note, If you happen to have multiple people using the git server, and you wanted to have a little more control over who has access to what, you could create different users on the server for each person you wanted to give access to, and then grant access to repos using the Linux filesystem permissions or something along those lines, but I'll defer to te aforementioned Pro Git book, as I myself don't share my private git server with anyone. Back to the topic at hand, we'll need to make sure we've got both the OpenSSH server and Git installed on the machine

# apt update
# apt install -y openssh-server git

With the necessary packages installed, let's fire up the SSH server.

# service ssh start

Then, from another machine (or in my example, the host machine), we can check the connection.

$ ssh git@localhost
The authenticity of host 'localhost (127.0.0.1)' can't be established.
ECDSA key fingerprint is SHA256:6A3OqYSNXdrt8gPNrdpCxSVP8UHKy1QYhOq7wB9120c.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added 'localhost' (ECDSA) to the list of known hosts.
Linux 2caac8f62d55 4.9.125-linuxkit #1 SMP Fri Sep 7 08:20:28 UTC 2018 x86_64
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
$

And we're in. Time to set some things up.

Customizing the Shell

I don't necessarily want full SSH access for my git user, as I already have another account on the server for myself, so I'm going to restrict the shell for the git user. Back at the root shell for the server, I'll set the default shell for the git user to git-shellgit-shell is pretty neat, and I'd encourage you to read more about it, but for this tutorial I'll just cover a few of the basics. First, let's set the shell for the git user.

# usermod -s /usr/bin/git-shell git

Now, if we attempt to SSH in to the machine again, we should get a different message:

$ ssh git@localhost
Linux 2caac8f62d55 4.9.125-linuxkit #1 SMP Fri Sep 7 08:20:28 UTC 2018 x86_64
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Sat Feb 16 20:26:16 2019 from 172.17.0.1
fatal: Interactive git shell is not enabled.
hint: ~/git-shell-commands should exist and have read and execute access.
Connection to localhost closed.

Note these two lines here:

fatal: Interactive git shell is not enabled.
hint: ~/git-shell-commands should exist and have read and execute access.

If you take a look at what git-shell does, it's basically a super limited version of a shell that only allows you to run preconfigured scripts, placed in the ~/git-shell-commands directory. Back as the root user, let's set this up now.

# mkdir /home/git/git-shell-commands
# chown git:git /home/git/git-shell-commands

I'm going to be adding a couple of files now so that the git-shell doesn't seem entirely useless. Anything we place in this directory will be offered to the user as an option to run, so I like to have a couple of convenience scripts in there that provide similar functionality to what a web host would offer.

The Special help Script

Upon connection, if you have a script called help in your ~/git-shell-commands directory, it will be run automatically. This would be a good place to put the names and descriptions of all the available commands the user can run. Here's what my /home/git/git-shell-commands/help script looks like:

#!/usr/bin/env bash
cat <<EOF
Welcome to the Brawner home private git server. The available commands are listed below:
delete [REPOSITORY_NAME]                           - delete a repository
ls                                                 - list the repositories
mirror [REPOSITORY_URL]                            - create a mirror of a repository on another server
new [REPOSITORY_NAME]                              - create a new repository
rename [OLD_REPOSITORY_NAME] [NEW_REPOSITORY_NAME] - rename a repository
EOF

If you add that file, make sure to set the permissions on it correctly:

# chmod +x /home/git/git-shell-commands/*

Then, when you connect to the server via SSH, you get this:

$ ssh git@localhost
Linux 2caac8f62d55 4.9.125-linuxkit #1 SMP Fri Sep 7 08:20:28 UTC 2018 x86_64
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Sat Feb 16 20:28:51 2019 from 172.17.0.1
Welcome to the Brawner home private git server. The available commands are listed below:
delete [REPOSITORY_NAME]                           - delete a repository
ls                                                 - list the repositories
mirror [REPOSITORY_URL]                            - create a mirror of a repository on another server
new [REPOSITORY_NAME]                              - create a new repository
rename [OLD_REPOSITORY_NAME] [NEW_REPOSITORY_NAME] - rename a repository
git>

Instead of having our connection dropped, we've now got this git> prompt where we can enter commands. We haven't added anything just yet, so let's start with the new command, so that we can create new repos.

Creating New Repositories Remotely

Here's what my version of this looks like:

#!/usr/bin/env bash

if [[ -z "$1" ]]; then
    echo "Please enter a repository name"
    exit 1
fi

if [[ -d "$1.git" ]]; then
    echo "Repo $1 already exists"
    exit 1
fi

/usr/bin/git init --bare "$1.git"
/usr/bin/git -C "$1.git" config http.receivepack true
echo "Successfully created repository $1"

It's a pretty simple script, but let's run through it just to make sure it's all clear. I first want to make sure that I've gotten a name for the new repository, and exit the script if it's missing.

if [[ -z "$1" ]]; then
    echo "Please enter a repository name"
    exit 1
fi

If I've got a name for it, then I want to make sure I'm not going to try to create a repository that would conflict with another file or folder on the server. Git does this check itself but I like to customize the error message for myself.

if [[ -d "$1.git" ]]; then
    echo "Repo $1 already exists"
    exit 1
fi

We then create a new empty repository...

/usr/bin/git init --bare "$1.git"

… and allow for remote push events.

/usr/bin/git -C "$1.git" config http.receivepack true

Lastly we echo out a little success message.

echo "Successfully created repository $1"

Let's add this to the server and give it a shot. Don't forget to add the executable flag to the file, or it won't work.

git> new my-repo
Initialized empty Git repository in /home/git/my-repo.git/
Successfully created repository my-repo

We now have an empty git repo we can clone. Back on the host machine, let's give this a clone to see that it's working.

$ git clone git@localhost:my-repo
Cloning into 'my-repo'...
warning: You appear to have cloned an empty repository.

Awesome! Let's add a file and push it.

$ cd my-repo
$ echo "# My Repo - A Git Server Test Repo" > README.md
$ git add README.md
$ git commit -m "Add README"
[master (root-commit) 545d3b7] Add README
1 file changed, 1 insertion(+)
create mode 100644 README.md
$ git push
Counting objects: 3, done.
Writing objects: 100% (3/3), 248 bytes | 248.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To localhost:my-repo
* [new branch]      master -> master

Everything seems to be working just fine! We were successfully able to clone, commit, and push. Let's take a look at some of the other commands I like to use on my git server. Creating repos is nice, but it's good to keep track of the ones we have too. For that, I've created the ls command.

Viewing Existing Repos

You might think I'm simply wrapping the ls command that comes built-in, but like any good engineer, I had to overengineer the problem. I don't want to see just any file, I'm looking for git repos. So, here goes:

#!/usr/bin/env bash
for repo in $(/usr/bin/find -maxdepth 1 -type d -name "*.git"); do /usr/bin/basename $repo .git; done | sort

This one's a bit shorter, and if you've put that in the right place and set the permissions correctly, running it should produce the following:

git> ls
my-repo

We've only got one repo, so the output is a little weak, but you get the idea.

Renaming Repositories Remotely

my-repo sounds a bit generic, and this was a test repo, so let's name it accordingly. test-repo sounds so much better! For this, I've got the renamecommand. Here's the script:

#!/usr/bin/env bash

if [[ -z "$1" ]]; then
    echo "Please enter a source repository name"
    exit 1
fi

if [[ -z "$2" ]]; then
    echo "Please enter a destination repository name"
    exit 1
fi

if [[ -d "$2.git" ]]; then
    echo "Repo $2 already exists"
    exit 1
fi

mv "$1.git" "$2.git"
echo "Successfully renamed repository $1 to $2"

We can break this one down pretty quickly too. We first want to make sure we have the source (or old) repository name...

if [[ -z "$1" ]]; then
    echo "Please enter a source repository name"
    exit 1
fi

… as well as the destination (or new) repository name...

if [[ -z "$2" ]]; then
    echo "Please enter a destination repository name"
    exit 1
fi

… and we don't want to try to rename a repository to an existing repository name...

if [[ -d "$2.git" ]]; then
    echo "Repo $2 already exists"
    exit 1
fi

… but if that's all good, then we go ahead and rename the directory.

mv "$1.git" "$2.git"
echo "Successfully renamed repository $1 to $2"

I don't like to repeat myself so I exclude the .git from my repository and just have the script append it for me. Let's give this one a go.

git> rename my-repo test-repo
Successfully renamed repository my-repo to test-repo
git> ls
test-repo

Another successfully working command.

Cleaning Up Unneeded/Unwanted Repos

With the testing out of the way, it'd be nice to be able to remove this repository. For that, I've got the delete command.

#!/usr/bin/env bash

if [[ -z "$1" ]]; then
    echo "Please enter a repository name"
    exit 1
fi

if [[ ! -d "$1.git" ]]; then
    echo "Repo $1 doesn't exist"
    exit 1
fi

rm -rf "$1.git"
echo "Successfully deleted repository $1"

I imagine by now this doesn't need much explanation, so I won't get into the details, except to say that I make sure I have a repo name to delete, and make sure that it exists, and then delete it. You could probably make this a bit more advanced by moving it to some sort of trash bin for 30 days before deleting it, but I'm not that cautious with my personal projects and when I decide it's time for something to go, it's time for it to go. Let's clean up this test repo.

git> delete test-repo
Successfully deleted repository test-repo
git> ls

Tracking Other Repos

Most hosts like GitHub also allow you to mirror other repositories, and that's a feature I tend to use quite a bit myself. To make this easier on my local git server, I use the mirror script.

#!/usr/bin/env bash

if [[ -z "$1" ]]; then
    echo "Please enter a repository URL"
    exit 1
fi

/usr/bin/git clone --mirror "$1"
echo "Successfully created a mirror of $1"

This one shouldn't need much explanation, so let's give it a run.

git> mirror https://github.com/wbrawner/SimpleMarkdown
Cloning into bare repository 'SimpleMarkdown.git'...
remote: Enumerating objects: 107, done.
remote: Counting objects: 100% (107/107), done.
remote: Compressing objects: 100% (57/57), done.
remote: Total 1832 (delta 33), reused 75 (delta 31), pack-reused 1725
Receiving objects: 100% (1832/1832), 783.26 KiB | 638.00 KiB/s, done.
Resolving deltas: 100% (891/891), done.
Successfully created a mirror of https://github.com/wbrawner/SimpleMarkdown
git> ls
SimpleMarkdown

Now, with mirroring there's an extra step involved. You need to set up a cron job to ensure that your mirrors always stay up-to-date. On most Linux systems, cron should be installed and ready to go. The same is not true for our Docker container though, so we'll need to set that up real quick (and don't forget to persist this should you decide to use the Docker setup permanently).

# apt install cron
# service cron start

With cron ready to go, we can add a line to our crontab with this command:

# crontab -eu git

The line to add is as follows:

0 * * * * for repo in $(/usr/bin/find /home/git -maxdepth 1 -type d -name "*.git"); do /usr/bin/git -C $repo remote update; done

Just make sure you've got an empty line after that. This will run through each of your mirrored repositories and pull any updates once per hour.

Wrapping Up

Congrats, you now have your own private git server! I may write some more posts later about setting up a simple web interface and getting a Git LFS setup to work here as well. Please reach out to me if you see me doing something horribly wrong or inefficient, or if you have other useful scripts that you like to use for your self-hosted git server.