I wanted a local Git server on my home network to store some repositories without relying on GitHub for everything. I don't need a web interface, so the built git-http-backend should be enough. Combined with a web server's access permissions I can have anonymous cloning, protected pushing and private repositories.
I'll be doing this on my FreeBSD RELEASE 14.2 home server, but most of the steps and settings should be the same for any similar *NIX like OS (except the jail bit). Assume all commands are run by root unless otherwise stated.
WARNING! What I'm describing below is only suitable for running within a home network, safely fire walled from the Internet. If you want to run this kind of set-up on the public Internet, make sure you add the usual safety measures (keypair SSH, firewall, HTTPS etc.).
Creating the Jail
As with most services I'll be running this out of a jail. Create the jail in your preferred way. Mine is called 'git', and created with this script, which will create a new jail in a new ZFS filesystem.
#!/bin/sh
# create-jail.sh
# Create a new jail with a standard jail config
# that can be adapted before first start
jails_dir="/usr/local/jails/containers"
base_dir="/usr/local/jails/media"
usage() {
echo "Usage $0 [-h] jailname"
echo "Create a new 'thick' jail in"
echo "$jails_dir"
echo "Options:"
echo " -h Display this help message"
}
case "$1" in
"-h")
usage
exit 0
;;
*)
# Carry on
jailname="$1"
;;
esac
jail_root="$jails_dir/$jailname"
release=$(uname -r | grep -o "^[1-9]\{2\}\.[0-9]")
base_archive="$base_dir/$release-RELEASE-base.txz"
# Check if a userland matching the release exists:
if [ -f "$base_archive" ]; then
echo "Found existing userland: $base_archive"
else
echo "Downloading userland..."
fetch "https://download.freebsd.org/ftp/releases/amd64/amd64/$release-RELEASE/base.txz" -o "$base_archive"
echo "...Download Complete"
fi
# Create the ZFS filesystem
# should throw an error if already exists
zfs create "zroot/jails/containers/$jailname"
# Extract filesystem
echo "Extracting filesystem"
tar -xf "$base_archive" -C "$jail_root" --unlink
# Copy in Local details
cp /etc/resolv.conf /etc/localtime "$jail_root/etc/."
# Update
echo "Running FreeBSD Update"
freebsd-update -b "$jail_root/" fetch install
echo "Jail $jailname created. Add the conf file into /etc/jail.conf.d, then:"
echo "# service jail start $jailname"In /etc/rc.conf I have the lines:
jail_enable="YES"
jail_parallel_start="YES"My /etc/jail.conf file consists of one line:
.include "/etc/jail.conf.d/*.conf";Which will import all the jail configuration files in /etc/jail.conf.d. This new jail will be imaginatively called 'git', and has the following configuration:
git {
# STARTUP/LOGGING
exec.start = "/bin/sh /etc/rc";
exec.stop = "/bin/sh /etc/rc.shutdown";
exec.consolelog = "/var/log/jail_console_${name}.log";
exec.clean;
mount.devfs; # Defaults to devfs ruleset 4
# HOSTNAME/PATH
host.hostname = "${name}";
path = "/usr/local/jails/containers/${name}";
# NETWORK
ip4.addr = <your IP Address>;
interface = <your interface>;
}Then start the jail with:
service jail start git
Once it's running we can install the three programs that we'll need: Git, Nginx and fast CGI wrap to connect the two. (If you get errors from pkg that it can't find the FreeBSD pkg repositories, check that the /etc/resolv.conf file in the jail has a valid name server set, e.g. you local network's names server if you're running one, or whichever public one you prefer.)
pkg -j git install git nginx fcgiwrap
Creating Repositories
Before we can set up the server, we need some repositories we can use. First create a directory for them:
mkdir -p /srv/git
The server will be running as user www, so we need to make sure directory, and those below it are accessible to that user.
chown www:www /srv/git
In there we can create the repos we want, which as per the Git book should be bare, and have shared permissions. By convention the names will end in .git. Additionally we need to set the receive pack option in the config file in the repo by adding this:
[http]
receivepack = trueThen run git update-server-info in the repository. Lastly make sure that the files' permissions are set for the www user:
chown -R www:www <myreponame>
I put these steps together into the following create-repo.sh script, as I'll be doing this every time I want a new remote repository.
#!/bin/sh
case "$1" in
*.git)
echo "not adding .git"
newrepo="/srv/git/$1"
;;
*)
newrepo="/srv/git/$1.git"
;;
esac
# Create the repo with default branch as main
git init --bare --shared -b main "$newrepo"
# set the receive pack option
echo "[http]
receivepack = true
" >> "$newrepo/config"
# Update the server info from within the repo
(cd "$newrepo" && git update-server-info )
# Change the ownership
chown -R www:www "$newrepo"Setting Up Fast CGI Wrap
Nginx1 cannot run CGI scripts by itself, and so we need fcgiwrap to pass the information from the Git backend to Nginx. We already installed it, so then we add the following options to /etc/rc.conf inside the jail2:
fcgiwrap_socket_owner="www"
fcgiwrap_user="www"Then we can enable and start it:
service fcgiwrap enable service fcgiwrap start
Then check that the owner of the socket is user www with:
ls -l /var/run/fcgiwrap/fcgiwrap.sock
If not, then check the fcgiwrap socket option is correct in rc.conf.
Setting Up Nginx
It's almost time to fire up the web server. We want to use the server to manage the access rights to the repositories, to split them into two:
- Private repositories, read and write only to authenticated users
- 'Public' Anonymous cloning, but requires authentication for pushing
Access is controlled by using basic HTTP authentication. There are additional packages we could install to manage those files, but really we just need to create a file in the format:
<username>:$<algorithm name>$<password hash>Which can be done with the already installed OpenSSL program:
printf '%s:%s\n' <username> $(openssl passwd -apr1 "<password>") >> /path/to/.htpasswd
I'll create one in /srv which will be for checking pusher access. Each use will need a new entry on a new line in the same file.
I'll also create a new folder at /srv/git/henry for my private repositories of bad poetry, and add a .htpasswd file there to protect them.
On FreeBSD the default location for the Nginx configuration file is in the directory /usr/local/etc/nginx/.
For the different levels of acces to the repositories we'll create multiple 'locations' within the server, but all will share the following parameteres for fcgiwrap, so create a file called 'git-cgi-params' with the following content in the Nginx configuration directory:
# Common GIT HTTP Backend CGI parameters for NGINX on FreeBSD (14.2)
# Addtionally requires PATH_INFO, but that's location dependent.
# fastcgi_param PATH_INFO $1;
client_max_body_size 0; # git pushes can be large
include /usr/local/etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/local/libexec/git-core/git-http-backend;
# export all repositories under GIT_PROJECT_ROOT
fastcgi_param GIT_HTTP_EXPORT_ALL "";
fastcgi_param REMOTE_USER $remote_user;
fastcgi_param GIT_PROJECT_ROOT /srv/git;
fastcgi_param GIT_HTTP_RECEIVE_PACK "";
fastcgi_pass unix:/var/run/fcgiwrap/fcgiwrap.sock;These settings will export all the repositories under the project root. If you want to control that on a per repository basis, remove the line with GIT_HTTP_EXPORT_ALL and add a magic git-daemon-export-ok file into those repositories that are OK to expose this way.
We can then import that into the main Nginx configuration file, which is as below at /usr/local/etc/nginx/nginx.conf3
# NGINX config to host a smart HTTP Git server
user www;
worker_processes 2;
events {
worker_connections 128;
}
http {
server_tokens off;
include mime.types;
charset utf-8;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name <IP/full domain name>;
# This is where the git repositories live on the server
root /srv/git;
# # Optional browsing, sometimes useful
# # to enable if you're not sure nginx is working
# # as expected. No authentication required to see everything.
# location ~ /git/.* {
# root /srv;
# autoindex on;
# autoindex_exact_size off;
# autoindex_localtime on;
# }
location ~* ^(/[\w-]+)/([\w-]+\.git.*) {
# Private repos, push & pull only with authentication
# based on the user's sub-directory
auth_basic "Restricted";
auth_basic_user_file /srv/git/$1/.htpasswd;
# Allows editing
fastcgi_param GIT_HTTP_RECEIVE_PACK "";
fastcgi_param PATH_INFO $1/$2;
include git-cgi-params;
}
location ~* ^(/.*git-receive-pack)$ {
# pushing requires authorisation
auth_basic "Restricted";
auth_basic_user_file /srv/.htpasswd;
# Allows editing
fastcgi_param GIT_HTTP_RECEIVE_PACK "";
fastcgi_param PATH_INFO $1;
include git-cgi-params;
}
location ~* ^(/.*) {
# Anonymous Clone
fastcgi_param PATH_INFO $1;
include git-cgi-params;
}
}
}Some details on the Nginx configuration file, which took me much longer than expected to get right4.
The three locations manage the three different types of access. The first is for the private repositories, with the URL matching regex ^(/[\w-]+)/([\w-]+\.git.*) matching any pattern that has a leading folder name, e.g. /henry/bad-poetry.git, and will authenticate on a .htpasswd file in that users's directory, using the $1 regex capturing group to set the directory to search in.
The second block limits access to any URLs that end in git-receive-pack. Git uses those URLs to push changes to the remote, and if you can't access them, then you can't push to the remote. I mostly saw lots of other solutions to getting this to work, none of which I managed to get to work. This 'simple' looking solution seems to work for me, but perhaps there are cases where it doesn't that I haven't encountered yet.
With that I was able to get the repositories working. Creating a new bare repository, cloning from with with:
git clone http://<your hostname or IP>/yourproject.git
and then using it in the usual way.
Backing Up
While these server remotes might be a form of back up, you should always have a real back-up. Therefore I created a cron job to run the below script that backs up the repos on a daily basis. The main trick is the --exclude='.htpasswd', to make sure you're not accidentally saving the authentication files.
#!/bin/sh
# Backup git repositories without .htpasswd files
# $1 folder to tar $2 output directory
today=$(date -j "+%Y-%m-%d")
if [ -d "$2" ]; then
tar -czf "$2/$today-repo-backup.tar.gz" --exclude='.htpasswd' "$1"
else
echo "$2 is not a directory."
exit 1
fiAdditional Features
There are a few things that would be 'nice to have', that I haven't implemented (yet?), that I had to give up on before this took much longer than I have the patience for. (Did I mention took me much longer than expected to get Nginx to do what I wanted?)
Create new repos without logging into the server. At the moment it's slightly annoying, even though I don't do it often. I think it must be possible to run a shell script via Nginx and CGI to do that if you hit a specific URL, but I ran out of time.Add a web view. I don't need one, but it would be nice. There is one built into Git calledgitweb, but I suspect cgit might be nicer solution.
Update 2025-06-13: Turns out I couldn't let it lie, so there's now an unexpected part 2, where I add GitWeb with the ability to create new repositories easily.
I only picked Nginx as I've already used it previously a few times, but I don't have a strong opinion. Other webservers are available, and ones like Apache don't require the Fast CGI Wrap. Generally Nginx seems fine, and it's configuration feels neater than Apache's, but the documentation is extremely terse, and I end up having to use many other sources to understand what I need to set up and how. ↩
You can make your main Nginx configuration file import many others, e.g. for different sites, and this is neater if you're running many sites from the same host. But here it's a single purpose jail, so I don't think there's any benefit. ↩
Many more hours than it should. In the end it seems very simple to just have three locations, for the three different types of access, but all the other examples I saw had much more complicated solutions with re-writes, if statements or try_files and named locations - none of which I managed to get to work. ↩