Ruby Git Server

January 05, 2025

I’ve been toying with a side project's POC and for one the moving pieces, I needed a Git server I could self-host.

One option I considered was using something like Gitea, but I eventually preferred something that was easier to customize and maintain by myself, so I needed a lighter solution. It turns out that Git itself already includes git-http-backend, a CGI program that allows to interact with a Git repository through the http[s] protocol.

In my case I wanted to integrate it with my Rails app to handle everything, including the authentication/authorization layer, from the same codebase. The solution ended being quite straightforward, I just had to create a small Rack app that calls the git-http-backend program and sends its output to the client:

class GitCGIHandler
  def initialize(script, project_root)
    @script = script
    @project_root = project_root
  end

  def call(env)
    request = ::Rack::Request.new(env)

    cgi_env = {
      "QUERY_STRING" => request.query_string,
      "REQUEST_METHOD" => request.request_method,
      "CONTENT_TYPE" => request.content_type,
      "CONTENT_LENGTH" => request.content_length.to_s,
      "SCRIPT_NAME" => request.script_name,
      "PATH_INFO" => request.path_info,
      "GIT_PROJECT_ROOT" => @project_root,
      "GIT_HTTP_EXPORT_ALL" => "1"
    }

    output, _, _ = Open3.capture3(cgi_env, @script, stdin_data: request.body)

    headers, body = parse_cgi_output(output)

    [ 200, headers, [ body ] ]
  rescue => error
    [ 500, { "Content-Type" => "text/plain" }, [ error.message ] ]
  end

  private

  def parse_cgi_output(output)
    headers, body = output.b.split("\r\n\r\n")
    headers = headers.split("\r\n").map { |line| line.split(": ") }.to_h
    [ headers, body ]
  end
end

git_cgi_handler.rb

And then we just need to plug it into our routes config:

 mount GitCGIHandler.new("git-http-backend", "/var/data/git"), at: "/git"

config/routes.rb

Then we need to initialize the remote repository:

git init --bare
git config http.receivepack true # this is needed to push the commits from the client to the server

And now we can start using the remote repository:

git clone http://localhost:3000/git/example.git
cd example
git commit --allow-empty -m 'first commit'
git push

And that’s it! Now we can customize it as much as we need/want. For example, if we want to require some sort of authentication/authorization, we can use any of the different available libraries or implement our own. Eg:

mount Rack::Builder.new {
  use Rack::Auth::Basic do |username, password|
    # validate username and password
    false
  end
  run GitCGIHandler.new("git-http-backend", "/var/data/git")
}, at: "/git"